--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------
Commit 8fac1134e288fdd300f8b2f1423ddd943bfb36a2
Parents : dda8a58
Author : Sudo-Ivan <ivan@quad4.io>
Signature : Signature validation error
Date : 2026-01-04T14:59:20-06:00
feat(call): improve call initiation and status handling with new properties for target hash and name; improve UI modals for tutorial and changelog visibility based on URL parameters
Changes
19 files changed, 3164 insertions(+), 1415 deletions(-)
Diff
diff --git a/meshchatx/src/frontend/components/App.vue b/meshchatx/src/frontend/components/App.vue
index 935a7f45..1eb77ed3 100644
--- a/meshchatx/src/frontend/components/App.vue
+++ b/meshchatx/src/frontend/components/App.vue
@@ -448,6 +448,8 @@
:was-declined="wasDeclined"
:voicemail-status="voicemailStatus"
:initiation-status="initiationStatus"
+ :initiation-target-hash="initiationTargetHash"
+ :initiation-target-name="initiationTargetName"
@hangup="onOverlayHangup"
@toggle-mic="onToggleMic"
@toggle-speaker="onToggleSpeaker"
@@ -564,6 +566,7 @@ export default {
isFetchingRingtone: false,
initiationStatus: null,
initiationTargetHash: null,
+ initiationTargetName: null,
isCallWindowOpen: false,
};
},
@@ -732,6 +735,7 @@ export default {
case "telephone_initiation_status": {
this.initiationStatus = json.status;
this.initiationTargetHash = json.target_hash;
+ this.initiationTargetName = json.target_name;
break;
}
case "new_voicemail": {
@@ -788,8 +792,22 @@ export default {
const response = await window.axios.get(`/api/v1/app/info`);
this.appInfo = response.data.app_info;
- // check if we should show tutorial or changelog (only on first load)
- if (!this.hasCheckedForModals) {
+ // check URL params for modal triggers
+ const urlParams = new URLSearchParams(window.location.search);
+ if (urlParams.has("show-guide")) {
+ this.$refs.tutorialModal.show();
+ // remove param from URL
+ urlParams.delete("show-guide");
+ const newUrl = window.location.pathname + (urlParams.toString() ? `?${urlParams.toString()}` : "");
+ window.history.replaceState({}, "", newUrl);
+ } else if (urlParams.has("changelog")) {
+ this.$refs.changelogModal.show();
+ // remove param from URL
+ urlParams.delete("changelog");
+ const newUrl = window.location.pathname + (urlParams.toString() ? `?${urlParams.toString()}` : "");
+ window.history.replaceState({}, "", newUrl);
+ } else if (!this.hasCheckedForModals) {
+ // check if we should show tutorial or changelog (only on first load)
this.hasCheckedForModals = true;
if (this.appInfo && !this.appInfo.tutorial_seen) {
this.$refs.tutorialModal.show();
@@ -1020,6 +1038,7 @@ export default {
this.voicemailStatus = response.data.voicemail;
this.initiationStatus = response.data.initiation_status;
this.initiationTargetHash = response.data.initiation_target_hash;
+ this.initiationTargetName = response.data.initiation_target_name;
// Handle power management for calls
if (ElectronUtils.isElectron()) {
diff --git a/meshchatx/src/frontend/components/ChangelogModal.vue b/meshchatx/src/frontend/components/ChangelogModal.vue
index 2d3ccf6f..c2276f8d 100644
--- a/meshchatx/src/frontend/components/ChangelogModal.vue
+++ b/meshchatx/src/frontend/components/ChangelogModal.vue
@@ -18,26 +18,21 @@
<v-toolbar-title class="text-xl font-bold tracking-tight text-gray-900 dark:text-white">
{{ $t("app.changelog_title", "What's New") }}
</v-toolbar-title>
- <v-chip
+ <span
v-if="version"
- size="x-small"
- color="blue"
- variant="flat"
- class="ml-3 font-black text-[10px] px-2 h-5 tracking-tighter uppercase rounded-sm"
+ class="ml-3 font-black text-[10px] px-2 h-5 tracking-tighter uppercase rounded-sm bg-blue-600 text-white inline-flex items-center"
>
v{{ version }}
- </v-chip>
+ </span>
</div>
<v-spacer></v-spacer>
- <v-btn
- icon
- size="small"
- variant="text"
- class="text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/10"
+ <button
+ type="button"
+ class="v-btn text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/10 p-2 transition-colors"
@click="close"
>
<v-icon>mdi-close</v-icon>
- </v-btn>
+ </button>
</v-toolbar>
<!-- Content -->
@@ -50,15 +45,7 @@
<div v-else-if="error" class="flex flex-col items-center justify-center h-full text-center space-y-4">
<v-icon icon="mdi-alert-circle-outline" size="64" color="red"></v-icon>
<div class="text-red-500 font-bold text-lg">{{ error }}</div>
- <v-btn
- color="blue"
- variant="flat"
- class="font-bold uppercase px-6"
- rounded="lg"
- @click="fetchChangelog"
- >
- Retry
- </v-btn>
+ <button type="button" class="primary-chip !px-6" @click="fetchChangelog">Retry</button>
</div>
<div
@@ -92,15 +79,9 @@
></v-checkbox>
</div>
<v-spacer></v-spacer>
- <v-btn
- variant="flat"
- color="blue"
- class="px-8 font-black tracking-tighter uppercase text-white dark:text-zinc-900"
- rounded="xl"
- @click="close"
- >
+ <button type="button" class="primary-chip !px-8 !h-10 !rounded-xl" @click="close">
{{ $t("common.close", "Close") }}
- </v-btn>
+ </button>
</v-card-actions>
</v-card>
</v-dialog>
@@ -117,14 +98,11 @@
{{ $t("app.changelog_title", "What's New") }}
</h1>
<div class="flex items-center gap-2">
- <v-chip
- color="blue"
- variant="flat"
- size="x-small"
- class="font-black text-[10px] px-2 h-5 rounded-sm"
+ <span
+ class="font-black text-[10px] px-2 h-5 rounded-sm bg-blue-600 text-white inline-flex items-center"
>
v{{ version }}
- </v-chip>
+ </span>
<span class="text-sm text-gray-500 font-medium">Full release history</span>
</div>
</div>
@@ -137,15 +115,7 @@
<div v-else-if="error" class="flex flex-col items-center justify-center py-20 text-center space-y-4">
<v-icon icon="mdi-alert-circle-outline" size="64" color="red"></v-icon>
<div class="text-red-500 font-bold text-lg">{{ error }}</div>
- <v-btn
- color="blue"
- variant="flat"
- class="font-bold uppercase px-6"
- rounded="lg"
- @click="fetchChangelog"
- >
- Retry
- </v-btn>
+ <button type="button" class="primary-chip !px-6" @click="fetchChangelog">Retry</button>
</div>
<div v-else class="changelog-content max-w-none prose dark:prose-invert pb-20">
diff --git a/meshchatx/src/frontend/components/CommandPalette.vue b/meshchatx/src/frontend/components/CommandPalette.vue
index 3ea87230..60bd508a 100644
--- a/meshchatx/src/frontend/components/CommandPalette.vue
+++ b/meshchatx/src/frontend/components/CommandPalette.vue
@@ -114,6 +114,7 @@ import MaterialDesignIcon from "./MaterialDesignIcon.vue";
import LxmfUserIcon from "./LxmfUserIcon.vue";
import GlobalEmitter from "../js/GlobalEmitter";
+import ToastUtils from "../js/ToastUtils";
export default {
name: "CommandPalette",
@@ -174,6 +175,94 @@ export default {
type: "navigation",
route: { name: "settings" },
},
+ {
+ id: "nav-ping",
+ title: "nav_ping",
+ description: "nav_ping_desc",
+ icon: "radar",
+ type: "navigation",
+ route: { name: "ping" },
+ },
+ {
+ id: "nav-rnprobe",
+ title: "nav_rnprobe",
+ description: "nav_rnprobe_desc",
+ icon: "radar",
+ type: "navigation",
+ route: { name: "rnprobe" },
+ },
+ {
+ id: "nav-rncp",
+ title: "nav_rncp",
+ description: "nav_rncp_desc",
+ icon: "swap-horizontal",
+ type: "navigation",
+ route: { name: "rncp" },
+ },
+ {
+ id: "nav-rnstatus",
+ title: "nav_rnstatus",
+ description: "nav_rnstatus_desc",
+ icon: "chart-line",
+ type: "navigation",
+ route: { name: "rnstatus" },
+ },
+ {
+ id: "nav-rnpath",
+ title: "nav_rnpath",
+ description: "nav_rnpath_desc",
+ icon: "route",
+ type: "navigation",
+ route: { name: "rnpath" },
+ },
+ {
+ id: "nav-translator",
+ title: "nav_translator",
+ description: "nav_translator_desc",
+ icon: "translate",
+ type: "navigation",
+ route: { name: "translator" },
+ },
+ {
+ id: "nav-forwarder",
+ title: "nav_forwarder",
+ description: "nav_forwarder_desc",
+ icon: "email-send-outline",
+ type: "navigation",
+ route: { name: "forwarder" },
+ },
+ {
+ id: "nav-documentation",
+ title: "nav_documentation",
+ description: "nav_documentation_desc",
+ icon: "book-open-variant",
+ type: "navigation",
+ route: { name: "documentation" },
+ },
+ {
+ id: "nav-micron-editor",
+ title: "nav_micron_editor",
+ description: "nav_micron_editor_desc",
+ icon: "code-tags",
+ type: "navigation",
+ route: { name: "micron-editor" },
+ },
+ {
+ id: "nav-rnode-flasher",
+ title: "nav_rnode_flasher",
+ description: "nav_rnode_flasher_desc",
+ icon: "flash",
+ type: "navigation",
+ route: { name: "rnode-flasher" },
+ },
+ {
+ id: "nav-debug-logs",
+ title: "nav_debug_logs",
+ description: "nav_debug_logs_desc",
+ icon: "console",
+ type: "navigation",
+ route: { name: "debug-logs" },
+ },
{
id: "action-sync",
title: "action_sync",
@@ -198,6 +287,22 @@ export default {
type: "action",
action: "toggle-orbit",
},
+ {
+ id: "action-getting-started",
+ title: "action_getting_started",
+ description: "action_getting_started_desc",
+ icon: "help-circle",
+ type: "action",
+ action: "show-tutorial",
+ },
+ {
+ id: "action-changelog",
+ title: "action_changelog",
+ description: "action_changelog_desc",
+ icon: "history",
+ type: "action",
+ action: "show-changelog",
+ },
],
};
},
@@ -340,7 +445,7 @@ export default {
} else if (result.type === "peer") {
this.$router.push({ name: "messages", params: { destinationHash: result.peer.destination_hash } });
} else if (result.type === "contact") {
- this.$router.push({ name: "call", query: { destination_hash: result.contact.remote_identity_hash } });
+ this.dialContact(result.contact.remote_identity_hash);
} else if (result.type === "action") {
if (result.action === "sync") {
GlobalEmitter.emit("sync-propagation-node");
@@ -352,9 +457,23 @@ export default {
});
} else if (result.action === "toggle-orbit") {
GlobalEmitter.emit("toggle-orbit");
+ } else if (result.action === "show-tutorial") {
+ GlobalEmitter.emit("show-tutorial");
+ } else if (result.action === "show-changelog") {
+ GlobalEmitter.emit("show-changelog");
}
}
},
+ async dialContact(hash) {
+ try {
+ await window.axios.get(`/api/v1/telephone/call/${hash}`);
+ if (this.$route.name !== "call") {
+ this.$router.push({ name: "call" });
+ }
+ } catch (e) {
+ ToastUtils.error(e.response?.data?.message || "Failed to initiate call");
+ }
+ },
},
};
</script>
diff --git a/meshchatx/src/frontend/components/Toast.vue b/meshchatx/src/frontend/components/Toast.vue
index 1cc77f9f..2c19876f 100644
--- a/meshchatx/src/frontend/components/Toast.vue
+++ b/meshchatx/src/frontend/components/Toast.vue
@@ -60,12 +60,13 @@ export default {
};
},
mounted() {
- GlobalEmitter.on("toast", (toast) => {
+ this.toastHandler = (toast) => {
this.add(toast);
- });
+ };
+ GlobalEmitter.on("toast", this.toastHandler);
},
beforeUnmount() {
- GlobalEmitter.off("toast");
+ GlobalEmitter.off("toast", this.toastHandler);
},
methods: {
add(toast) {
diff --git a/meshchatx/src/frontend/components/TutorialModal.vue b/meshchatx/src/frontend/components/TutorialModal.vue
index e1702acb..6023cfe9 100644
--- a/meshchatx/src/frontend/components/TutorialModal.vue
+++ b/meshchatx/src/frontend/components/TutorialModal.vue
@@ -9,7 +9,23 @@
persistent
@update:model-value="onVisibleUpdate"
>
- <v-card class="flex flex-col h-full bg-white dark:bg-zinc-950 border-0 overflow-hidden">
+ <v-card class="flex flex-col h-full bg-white dark:bg-zinc-950 border-0 overflow-hidden relative">
+ <!-- Settings Controls -->
+ <div class="absolute top-4 left-4 z-50 flex items-center gap-1">
+ <LanguageSelector @language-change="onLanguageChange" />
+ <button
+ type="button"
+ class="rounded-full p-1.5 sm:p-2 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 transition-colors"
+ :title="config?.theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'"
+ @click="toggleTheme"
+ >
+ <MaterialDesignIcon
+ :icon-name="config?.theme === 'dark' ? 'brightness-6' : 'brightness-4'"
+ class="w-5 h-5 sm:w-6 sm:h-6"
+ />
+ </button>
+ </div>
+
<!-- Progress Bar -->
<div class="w-full h-1.5 bg-gray-100 dark:bg-zinc-900 overflow-hidden flex">
<div
@@ -35,10 +51,10 @@
</div>
<div class="space-y-2">
<h1 class="text-4xl font-black tracking-tight text-gray-900 dark:text-white">
- Welcome to <span class="text-blue-500">MeshChatX</span>
+ {{ $t("tutorial.welcome") }} <span class="text-blue-500">MeshChatX</span>
</h1>
<p class="text-lg text-gray-600 dark:text-zinc-400 max-w-md mx-auto">
- The future of off-grid communication. Secure, decentralized, and unstoppable.
+ {{ $t("tutorial.welcome_desc") }}
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 w-full mt-8">
@@ -47,9 +63,11 @@
>
<v-icon icon="mdi-shield-lock" color="blue" size="32"></v-icon>
<div>
- <div class="font-bold text-gray-900 dark:text-white">Security & Performance</div>
- <div class="text-sm text-gray-500">
- Massive improvements in speed, security, and crash recovery.
+ <div class="font-bold text-gray-900 dark:text-white">
+ {{ $t("tutorial.security") }}
+ </div>
+ <div class="text-sm text-gray-900 dark:text-white">
+ {{ $t("tutorial.security_desc") }}
</div>
</div>
</div>
@@ -58,9 +76,9 @@
>
<v-icon icon="mdi-map-marker-path" color="purple" size="32"></v-icon>
<div>
- <div class="font-bold text-gray-900 dark:text-white">Maps</div>
- <div class="text-sm text-gray-500">
- OpenLayers w/ MBTiles support and custom API endpoints.
+ <div class="font-bold text-gray-900 dark:text-white">{{ $t("tutorial.maps") }}</div>
+ <div class="text-sm text-gray-900 dark:text-white">
+ {{ $t("tutorial.maps_desc") }}
</div>
</div>
</div>
@@ -69,9 +87,11 @@
>
<v-icon icon="mdi-phone" color="green" size="32"></v-icon>
<div>
- <div class="font-bold text-gray-900 dark:text-white">Full LXST Voice</div>
- <div class="text-sm text-gray-500">
- Voicemail, ringtones, phonebook, and contact sharing.
+ <div class="font-bold text-gray-900 dark:text-white">
+ {{ $t("tutorial.voice") }}
+ </div>
+ <div class="text-sm text-gray-900 dark:text-white">
+ {{ $t("tutorial.voice_desc") }}
</div>
</div>
</div>
@@ -80,9 +100,11 @@
>
<v-icon icon="mdi-tools" color="orange" size="32"></v-icon>
<div>
- <div class="font-bold text-gray-900 dark:text-white">Advanced Tools</div>
- <div class="text-sm text-gray-500">
- Micron Editor, Paper Messages, RNS tools, Docs.
+ <div class="font-bold text-gray-900 dark:text-white">
+ {{ $t("tutorial.tools") }}
+ </div>
+ <div class="text-sm text-gray-900 dark:text-white">
+ {{ $t("tutorial.tools_desc") }}
</div>
</div>
</div>
@@ -91,9 +113,24 @@
>
<v-icon icon="mdi-database-search" color="teal" size="32"></v-icon>
<div>
- <div class="font-bold text-gray-900 dark:text-white">Crawler & Archiver</div>
- <div class="text-sm text-gray-500">
- Automated network crawling and page archiving.
+ <div class="font-bold text-gray-900 dark:text-white">
+ {{ $t("tutorial.archiver") }}
+ </div>
+ <div class="text-sm text-gray-900 dark:text-white">
+ {{ $t("tutorial.archiver_desc") }}
+ </div>
+ </div>
+ </div>
+ <div
+ class="flex items-start gap-4 p-4 rounded-2xl bg-gray-50 dark:bg-zinc-900 text-left border border-gray-100 dark:border-zinc-800 transition-all hover:scale-[1.03] hover:shadow-xl hover:z-10"
+ >
+ <v-icon icon="mdi-account-cancel" color="amber" size="32"></v-icon>
+ <div>
+ <div class="font-bold text-gray-900 dark:text-white">
+ {{ $t("tutorial.banishment") }}
+ </div>
+ <div class="text-sm text-gray-900 dark:text-white">
+ {{ $t("tutorial.banishment_desc") }}
</div>
</div>
</div>
@@ -103,10 +140,10 @@
<v-icon icon="mdi-keyboard-outline" color="red" size="32"></v-icon>
<div>
<div class="font-bold text-gray-900 dark:text-white">
- Command Palette + Keybindings
+ {{ $t("tutorial.palette") }}
</div>
- <div class="text-sm text-gray-500">
- Navigate everything instantly and customize shortcuts.
+ <div class="text-sm text-gray-900 dark:text-white">
+ {{ $t("tutorial.palette_desc") }}
</div>
</div>
</div>
@@ -115,256 +152,108 @@
>
<v-icon icon="mdi-translate" color="cyan" size="32"></v-icon>
<div>
- <div class="font-bold text-gray-900 dark:text-white">i18n Support</div>
- <div class="text-sm text-gray-500">Available in English, German, and Russian.</div>
+ <div class="font-bold text-gray-900 dark:text-white">{{ $t("tutorial.i18n") }}</div>
+ <div class="text-sm text-gray-900 dark:text-white">
+ {{ $t("tutorial.i18n_desc") }}
+ </div>
</div>
</div>
</div>
+ <div
+ class="w-full flex justify-end items-center gap-2 mt-4 px-4 text-gray-400 dark:text-zinc-500"
+ >
+ <v-icon icon="mdi-plus" size="16"></v-icon>
+ <span class="text-xs font-bold uppercase tracking-widest">{{
+ $t("tutorial.more_features")
+ }}</span>
+ </div>
</div>
<!-- Step 2: Add Interface -->
<div v-else-if="currentStep === 2" key="step2" class="space-y-6">
<div class="text-center space-y-2">
- <h2 class="text-2xl font-bold text-gray-900 dark:text-white">Connect to the Mesh</h2>
- <p class="text-gray-600 dark:text-zinc-400">
- To send messages, you need to connect to a Reticulum interface.
+ <h2 class="text-2xl font-bold text-gray-900 dark:text-white">
+ {{ $t("tutorial.connect") }}
+ </h2>
+ <p class="text-gray-600 dark:text-zinc-400 text-base">
+ {{ $t("tutorial.connect_desc") }}
</p>
</div>
- <div v-if="!showCustomForm" class="space-y-4">
+ <div class="space-y-4">
<!-- Community Interfaces -->
<div
- class="bg-gray-50 dark:bg-zinc-900 rounded-3xl p-4 border border-gray-100 dark:border-zinc-800"
+ class="bg-gray-50 dark:bg-zinc-900 rounded-3xl p-3 border border-gray-100 dark:border-zinc-800"
>
- <div class="flex items-center gap-2 mb-4 px-2">
+ <div class="flex items-center gap-2 mb-3 px-2 text-sm">
<v-icon icon="mdi-web" color="blue"></v-icon>
- <span class="font-bold text-gray-900 dark:text-white">Suggested Public Relays</span>
+ <span class="font-bold text-gray-900 dark:text-white">{{
+ $t("tutorial.suggested_networks")
+ }}</span>
</div>
- <div class="space-y-2 max-h-[300px] overflow-y-auto pr-2">
+ <div class="space-y-2 max-h-[260px] overflow-y-auto pr-2">
<div
v-for="iface in communityInterfaces"
:key="iface.name"
- class="flex items-center justify-between p-3 bg-white dark:bg-zinc-800 rounded-2xl border border-gray-100 dark:border-zinc-700 hover:border-blue-400 transition-colors cursor-pointer"
+ class="flex items-center justify-between p-3 bg-white dark:bg-zinc-800 rounded-xl border border-gray-100 dark:border-zinc-700 hover:border-blue-400 transition-colors cursor-pointer"
@click="selectCommunityInterface(iface)"
>
<div class="flex flex-col">
- <span class="font-bold text-gray-900 dark:text-white">{{
+ <span class="font-bold text-gray-900 dark:text-white text-sm">{{
iface.name
}}</span>
- <span class="text-xs text-gray-500"
+ <span class="text-[11px] text-gray-500 dark:text-zinc-400"
>{{ iface.target_host }}:{{ iface.target_port }}</span
>
</div>
- <div class="flex items-center gap-3">
+ <div class="flex items-center gap-2">
<span
v-if="iface.online"
- class="flex items-center gap-1 text-[10px] font-bold text-green-500 uppercase tracking-wider"
+ class="flex items-center gap-1 text-[9px] font-bold text-green-500 uppercase tracking-[0.2em]"
>
<span
class="w-1.5 h-1.5 rounded-full bg-green-500 animate-pulse"
></span>
- Online
+ {{ $t("tutorial.online") }}
</span>
- <v-btn
- size="small"
- variant="flat"
- color="#3b82f6"
- rounded="lg"
- class="font-bold text-white"
+ <button
+ type="button"
+ class="px-3 py-1 text-[11px] rounded-xl bg-blue-600 hover:bg-blue-500 text-white font-semibold shadow-sm transition-all"
@click.stop="selectCommunityInterface(iface)"
- >Use</v-btn
>
+ {{ $t("tutorial.use") }}
+ </button>
</div>
</div>
- <div v-if="loadingInterfaces" class="flex justify-center py-4">
+ <div v-if="loadingInterfaces" class="flex justify-center py-3">
<v-progress-circular indeterminate color="blue" size="24"></v-progress-circular>
</div>
</div>
</div>
-
- <div class="text-center text-sm text-gray-500 dark:text-zinc-500">OR</div>
-
- <!-- Custom Interface -->
- <v-btn
- block
- variant="outlined"
- color="blue"
- rounded="xl"
- class="font-bold h-12 dark:text-blue-400"
- prepend-icon="mdi-plus"
- @click="showCustomInterfacePrompt"
- >
- Manual Configuration
- </v-btn>
</div>
- <!-- Custom Interface Form -->
- <div v-else class="space-y-4 animate-in fade-in slide-in-from-bottom-4 duration-300">
- <div
- class="bg-gray-50 dark:bg-zinc-900 rounded-3xl p-6 border border-gray-100 dark:border-zinc-800 space-y-4"
+ <div class="flex flex-col items-center gap-3 text-sm text-gray-900 dark:text-white">
+ <p class="max-w-sm text-center">
+ {{ $t("tutorial.custom_interfaces_desc") }}
+ </p>
+ <button
+ type="button"
+ class="px-5 py-2 text-[11px] rounded-xl border border-gray-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-gray-700 dark:text-zinc-300 font-semibold shadow-sm transition-all hover:bg-gray-50 dark:hover:bg-zinc-700 hover:border-blue-400 dark:hover:border-blue-500"
+ @click="gotoAddInterface"
>
- <div class="flex items-center justify-between mb-2">
- <span class="font-bold text-gray-900 dark:text-white">Custom Interface</span>
- <v-btn size="small" variant="text" color="gray" @click="showCustomForm = false"
- >Cancel</v-btn
- >
- </div>
-
- <v-select
- v-model="newInterface.type"
- :items="interfaceTypes"
- label="Interface Type"
- variant="outlined"
- density="comfortable"
- rounded="lg"
- hide-details
- class="bg-white dark:bg-zinc-800"
- ></v-select>
-
- <v-text-field
- v-model="newInterface.name"
- label="Interface Name"
- placeholder="e.g. My Relay"
- variant="outlined"
- density="comfortable"
- rounded="lg"
- hide-details
- class="bg-white dark:bg-zinc-800"
- ></v-text-field>
-
- <!-- TCP Client Fields -->
- <template v-if="newInterface.type === 'TCPClientInterface'">
- <v-text-field
- v-model="newInterface.target_host"
- label="Target Host"
- placeholder="e.g. 1.2.3.4 or example.com"
- variant="outlined"
- density="comfortable"
- rounded="lg"
- hide-details
- class="bg-white dark:bg-zinc-800"
- ></v-text-field>
- <v-text-field
- v-model="newInterface.target_port"
- label="Target Port"
- type="number"
- placeholder="4242"
- variant="outlined"
- density="comfortable"
- rounded="lg"
- hide-details
- class="bg-white dark:bg-zinc-800"
- ></v-text-field>
- </template>
-
- <!-- Server/UDP Fields -->
- <template
- v-if="
- newInterface.type === 'TCPServerInterface' ||
- newInterface.type === 'UDPInterface'
- "
- >
- <v-text-field
- v-model="newInterface.listen_ip"
- label="Listen IP"
- placeholder="0.0.0.0"
- variant="outlined"
- density="comfortable"
- rounded="lg"
- hide-details
- class="bg-white dark:bg-zinc-800"
- ></v-text-field>
- <v-text-field
- v-model="newInterface.listen_port"
- label="Listen Port"
- type="number"
- placeholder="4242"
- variant="outlined"
- density="comfortable"
- rounded="lg"
- hide-details
- class="bg-white dark:bg-zinc-800"
- ></v-text-field>
- </template>
-
- <!-- RNode/Serial/KISS Fields -->
- <template
- v-if="
- [
- 'RNodeInterface',
- 'RNodeMultiInterface',
- 'SerialInterface',
- 'KISSInterface',
- 'AX25KISSInterface',
- ].includes(newInterface.type)
- "
- >
- <v-select
- v-model="newInterface.port"
- :items="comports"
- item-title="device"
- item-value="device"
- label="Serial Port"
- variant="outlined"
- density="comfortable"
- rounded="lg"
- hide-details
- class="bg-white dark:bg-zinc-800"
- ></v-select>
- <v-text-field
- v-if="
- ['SerialInterface', 'KISSInterface', 'AX25KISSInterface'].includes(
- newInterface.type
- )
- "
- v-model="newInterface.speed"
- label="Baud Rate"
- type="number"
- variant="outlined"
- density="comfortable"
- rounded="lg"
- hide-details
- class="bg-white dark:bg-zinc-800"
- ></v-text-field>
- </template>
-
- <!-- Pipe Interface Fields -->
- <template v-if="newInterface.type === 'PipeInterface'">
- <v-text-field
- v-model="newInterface.command"
- label="Command"
- placeholder="e.g. netcat -l 5757"
- variant="outlined"
- density="comfortable"
- rounded="lg"
- hide-details
- class="bg-white dark:bg-zinc-800"
- ></v-text-field>
- </template>
-
- <v-btn
- block
- color="blue"
- variant="flat"
- rounded="lg"
- class="font-bold h-12 text-white mt-4"
- @click="addCustomInterface"
- >
- Add Interface
- </v-btn>
- <p class="text-[10px] text-center text-gray-500">
- More advanced options are available in the full Interface settings after setup.
- </p>
- </div>
+ {{ $t("tutorial.open_interfaces") }}
+ </button>
</div>
</div>
<!-- Step 3: Documentation & Tools -->
<div v-else-if="currentStep === 3" key="step3" class="space-y-6">
<div class="text-center space-y-2">
- <h2 class="text-2xl font-bold text-gray-900 dark:text-white">Learn & Create</h2>
+ <h2 class="text-2xl font-bold text-gray-900 dark:text-white">
+ {{ $t("tutorial.learn_create") }}
+ </h2>
<p class="text-gray-600 dark:text-zinc-400">
- Discover how to use MeshChatX to its full potential.
+ {{ $t("tutorial.learn_create_desc") }}
</p>
</div>
@@ -374,27 +263,27 @@
>
<v-icon icon="mdi-book-open-variant" color="blue" size="32"></v-icon>
<div>
- <div class="font-bold text-gray-900 dark:text-white">Documentation</div>
- <div class="text-sm text-gray-500 mb-2">
- Read the official MeshChatX and Reticulum documentation.
+ <div class="font-bold text-gray-900 dark:text-white">
+ {{ $t("tutorial.documentation") }}
+ </div>
+ <div class="text-sm text-gray-900 dark:text-white mb-2">
+ {{ $t("tutorial.documentation_desc") }}
</div>
<div class="flex gap-2">
- <v-btn
- size="x-small"
- variant="tonal"
- color="blue"
+ <a
href="/meshchatx-docs/index.html"
target="_blank"
- >MeshChatX Docs</v-btn
+ class="px-3 py-1 text-[10px] rounded-xl bg-blue-600 hover:bg-blue-500 text-white font-semibold shadow-sm transition-all inline-block"
>
- <v-btn
- size="x-small"
- variant="tonal"
- color="purple"
- href="https://reticulum.network/manual/"
+ {{ $t("tutorial.meshchatx_docs") }}
+ </a>
+ <a
+ href="/reticulum-docs/index.html"
target="_blank"
- >Reticulum Docs</v-btn
+ class="px-3 py-1 text-[10px] rounded-xl border border-gray-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-gray-700 dark:text-zinc-300 font-semibold shadow-sm transition-all hover:bg-gray-50 dark:hover:bg-zinc-700 hover:border-blue-400 dark:hover:border-blue-500 inline-block"
>
+ {{ $t("tutorial.reticulum_docs") }}
+ </a>
</div>
</div>
</div>
@@ -404,20 +293,81 @@
>
<v-icon icon="mdi-file-document-edit-outline" color="orange" size="32"></v-icon>
<div>
- <div class="font-bold text-gray-900 dark:text-white">Micron Editor</div>
- <div class="text-sm text-gray-500 mb-2">
- Take a look at the Micron Editor for a guide on creating mesh-native pages.
+ <div class="font-bold text-gray-900 dark:text-white">
+ {{ $t("tutorial.micron_editor") }}
+ </div>
+ <div class="text-sm text-gray-900 dark:text-white mb-2">
+ {{ $t("tutorial.micron_editor_desc") }}
</div>
- <v-btn
- size="x-small"
- variant="tonal"
- color="orange"
- @click="
- $router.push({ name: 'micron-editor' });
- visible = false;
- "
- >Open Micron Editor</v-btn
+ <button
+ type="button"
+ class="px-3 py-1 text-[10px] rounded-xl border border-gray-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-gray-700 dark:text-zinc-300 font-semibold shadow-sm transition-all hover:bg-gray-50 dark:hover:bg-zinc-700 hover:border-blue-400 dark:hover:border-blue-500"
+ @click="gotoRoute('micron-editor')"
>
+ {{ $t("tutorial.open_micron_editor") }}
+ </button>
+ </div>
+ </div>
+
+ <div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
+ <div
+ class="flex items-start gap-4 p-4 rounded-2xl bg-gray-50 dark:bg-zinc-900 text-left border border-gray-100 dark:border-zinc-800 cursor-pointer hover:border-blue-500 transition-colors"
+ @click="gotoRoute('nomadnetwork')"
+ >
+ <v-icon icon="mdi-earth" color="purple" size="24"></v-icon>
+ <div>
+ <div class="font-bold text-gray-900 dark:text-white text-xs">
+ {{ $t("tutorial.paper_messages") }}
+ </div>
+ <div class="text-[10px] text-gray-900 dark:text-white">
+ {{ $t("tutorial.paper_messages_desc") }}
+ </div>
+ </div>
+ </div>
+
+ <div
+ class="flex items-start gap-4 p-4 rounded-2xl bg-gray-50 dark:bg-zinc-900 text-left border border-gray-100 dark:border-zinc-800 cursor-pointer hover:border-blue-500 transition-colors"
+ @click="gotoRoute('messages')"
+ >
+ <v-icon icon="mdi-message-text-outline" color="green" size="24"></v-icon>
+ <div>
+ <div class="font-bold text-gray-900 dark:text-white text-xs">
+ {{ $t("tutorial.send_messages") }}
+ </div>
+ <div class="text-[10px] text-gray-900 dark:text-white">
+ {{ $t("tutorial.send_messages_desc") }}
+ </div>
+ </div>
+ </div>
+
+ <div
+ class="flex items-start gap-4 p-4 rounded-2xl bg-gray-50 dark:bg-zinc-900 text-left border border-gray-100 dark:border-zinc-800 cursor-pointer hover:border-blue-500 transition-colors"
+ @click="gotoRoute('network-visualiser')"
+ >
+ <v-icon icon="mdi-hub" color="teal" size="24"></v-icon>
+ <div>
+ <div class="font-bold text-gray-900 dark:text-white text-xs">
+ {{ $t("tutorial.explore_nodes") }}
+ </div>
+ <div class="text-[10px] text-gray-900 dark:text-white">
+ {{ $t("tutorial.explore_nodes_desc") }}
+ </div>
+ </div>
+ </div>
+
+ <div
+ class="flex items-start gap-4 p-4 rounded-2xl bg-gray-50 dark:bg-zinc-900 text-left border border-gray-100 dark:border-zinc-800 cursor-pointer hover:border-blue-500 transition-colors"
+ @click="gotoRoute('call')"
+ >
+ <v-icon icon="mdi-phone-in-talk-outline" color="red" size="24"></v-icon>
+ <div>
+ <div class="font-bold text-gray-900 dark:text-white text-xs">
+ {{ $t("tutorial.voice_calls") }}
+ </div>
+ <div class="text-[10px] text-gray-900 dark:text-white">
+ {{ $t("tutorial.voice_calls_desc") }}
+ </div>
+ </div>
</div>
</div>
</div>
@@ -434,20 +384,19 @@
<div class="absolute inset-0 bg-green-500/20 rounded-full animate-ping opacity-20"></div>
</div>
<div class="space-y-3">
- <h2 class="text-3xl font-black text-gray-900 dark:text-white">Ready to Roll!</h2>
+ <h2 class="text-3xl font-black text-gray-900 dark:text-white">
+ {{ $t("tutorial.ready") }}
+ </h2>
<p class="text-lg text-gray-600 dark:text-zinc-400 max-w-md mx-auto">
- Everything is set up. You need to restart the application for the changes to take
- effect.
+ {{ $t("tutorial.ready_desc") }}
</p>
</div>
<div
+ v-if="interfaceAddedViaTutorial"
class="p-4 bg-amber-50 dark:bg-amber-900/20 rounded-2xl border border-amber-100 dark:border-amber-900/30 text-amber-700 dark:text-amber-400 text-sm flex gap-3 max-w-md text-left"
>
<v-icon icon="mdi-information-outline" class="shrink-0"></v-icon>
- <span
- >If you're running in Docker, make sure your container auto-restarts or start it
- manually after it stops.</span
- >
+ <span>{{ $t("tutorial.docker_note") }}</span>
</div>
</div>
</transition>
@@ -456,51 +405,65 @@
<!-- Footer -->
<v-divider class="dark:border-zinc-900"></v-divider>
<v-card-actions class="px-6 py-6 bg-gray-50 dark:bg-zinc-950/50 flex justify-between">
- <v-btn
+ <button
v-if="currentStep > 1 && currentStep < totalSteps"
- variant="text"
- color="gray"
- class="font-bold text-gray-700 dark:text-zinc-300"
+ type="button"
+ class="px-6 py-2.5 rounded-xl border border-gray-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-gray-700 dark:text-zinc-300 font-semibold text-sm shadow-sm transition-all hover:bg-gray-50 dark:hover:bg-zinc-700 hover:border-blue-400 dark:hover:border-blue-500"
@click="currentStep--"
- >Back</v-btn
>
+ {{ $t("tutorial.back") }}
+ </button>
<div v-else></div>
<div class="flex gap-3">
- <v-btn
+ <button
v-if="currentStep < totalSteps"
- variant="text"
- color="gray"
- class="font-bold opacity-50 hover:opacity-100 text-gray-700 dark:text-zinc-300"
+ type="button"
+ class="px-6 py-2.5 rounded-xl border border-gray-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-gray-700 dark:text-zinc-300 font-semibold text-sm shadow-sm transition-all opacity-50 hover:opacity-100 hover:bg-gray-50 dark:hover:bg-zinc-700"
@click="skipTutorial"
- >Skip</v-btn
>
+ {{ $t("tutorial.skip") }}
+ </button>
- <v-btn
+ <button
v-if="currentStep < totalSteps"
- variant="flat"
- color="#3b82f6"
- class="px-8 font-black tracking-wide h-12 text-white"
- rounded="xl"
+ type="button"
+ class="px-8 h-12 rounded-xl bg-blue-600 hover:bg-blue-500 text-white font-semibold text-sm shadow-sm transition-all"
@click="nextStep"
- >Next</v-btn
>
+ {{ $t("tutorial.next") }}
+ </button>
- <v-btn
+ <button
v-else
- variant="flat"
- color="#22c55e"
- class="px-8 font-black tracking-wide h-12 text-white"
- rounded="xl"
+ type="button"
+ class="px-8 h-12 rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white font-semibold text-sm shadow-sm transition-all"
@click="finishAndRestart"
- >Restart & Start Chatting</v-btn
>
+ {{ $t("tutorial.restart_start") }}
+ </button>
</div>
</v-card-actions>
</v-card>
</v-dialog>
- <div v-else class="flex flex-col h-full bg-white dark:bg-zinc-950 overflow-hidden">
+ <div v-else class="flex flex-col h-full bg-white dark:bg-zinc-950 overflow-hidden relative">
+ <!-- Settings Controls -->
+ <div class="absolute top-4 left-4 z-50 flex items-center gap-1">
+ <LanguageSelector @language-change="onLanguageChange" />
+ <button
+ type="button"
+ class="rounded-full p-1.5 sm:p-2 text-gray-600 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-zinc-800 transition-colors"
+ :title="config?.theme === 'dark' ? 'Switch to light mode' : 'Switch to dark mode'"
+ @click="toggleTheme"
+ >
+ <MaterialDesignIcon
+ :icon-name="config?.theme === 'dark' ? 'brightness-6' : 'brightness-4'"
+ class="w-5 h-5 sm:w-6 sm:h-6"
+ />
+ </button>
+ </div>
+
<!-- Progress Bar -->
<div class="w-full h-1.5 bg-gray-100 dark:bg-zinc-900 overflow-hidden flex">
<div
@@ -530,10 +493,10 @@
</div>
<div class="space-y-4">
<h1 class="text-5xl font-black tracking-tight text-gray-900 dark:text-white">
- Welcome to <span class="text-blue-500">MeshChatX</span>
+ {{ $t("tutorial.welcome") }} <span class="text-blue-500">MeshChatX</span>
</h1>
<p class="text-xl text-gray-600 dark:text-zinc-400 max-w-2xl mx-auto">
- The future of off-grid communication. Secure, decentralized, and unstoppable.
+ {{ $t("tutorial.welcome_desc") }}
</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 w-full mt-12">
@@ -543,11 +506,10 @@
<v-icon icon="mdi-shield-lock" color="blue" size="40"></v-icon>
<div>
<div class="font-bold text-xl text-gray-900 dark:text-white">
- Security & Performance
+ {{ $t("tutorial.security") }}
</div>
- <div class="text-gray-500">
- Massive improvements in speed, security, and integrity with built-in crash
- recovery.
+ <div class="text-gray-900 dark:text-white">
+ {{ $t("tutorial.security_desc_page") }}
</div>
</div>
</div>
@@ -556,10 +518,11 @@
>
<v-icon icon="mdi-map-marker-path" color="purple" size="40"></v-icon>
<div>
- <div class="font-bold text-xl text-gray-900 dark:text-white">Maps</div>
- <div class="text-gray-500">
- OpenLayers support with offline MBTiles and custom API endpoints for online
- maps.
+ <div class="font-bold text-xl text-gray-900 dark:text-white">
+ {{ $t("tutorial.maps") }}
+ </div>
+ <div class="text-gray-900 dark:text-white">
+ {{ $t("tutorial.maps_desc_page") }}
</div>
</div>
</div>
@@ -568,10 +531,11 @@
>
<v-icon icon="mdi-phone" color="green" size="40"></v-icon>
<div>
- <div class="font-bold text-xl text-gray-900 dark:text-white">Full LXST Voice</div>
- <div class="text-gray-500">
- Crystal clear voice calls over mesh. Voicemail, custom ringtones, and phonebook
- discovery.
+ <div class="font-bold text-xl text-gray-900 dark:text-white">
+ {{ $t("tutorial.voice") }}
+ </div>
+ <div class="text-gray-900 dark:text-white">
+ {{ $t("tutorial.voice_desc_page") }}
</div>
</div>
</div>
@@ -580,9 +544,11 @@
>
<v-icon icon="mdi-tools" color="orange" size="40"></v-icon>
<div>
- <div class="font-bold text-xl text-gray-900 dark:text-white">Advanced Tools</div>
- <div class="text-gray-500">
- Micron editor, paper messages, RNS tools, and integrated documentation.
+ <div class="font-bold text-xl text-gray-900 dark:text-white">
+ {{ $t("tutorial.tools") }}
+ </div>
+ <div class="text-gray-900 dark:text-white">
+ {{ $t("tutorial.tools_desc_page") }}
</div>
</div>
</div>
@@ -592,10 +558,23 @@
<v-icon icon="mdi-database-search" color="teal" size="40"></v-icon>
<div>
<div class="font-bold text-xl text-gray-900 dark:text-white">
- Crawler & Archiver
+ {{ $t("tutorial.archiver") }}
+ </div>
+ <div class="text-gray-900 dark:text-white">
+ {{ $t("tutorial.archiver_desc_page") }}
+ </div>
+ </div>
+ </div>
+ <div
+ class="flex items-start gap-6 p-6 rounded-3xl bg-gray-50 dark:bg-zinc-900 text-left border border-gray-100 dark:border-zinc-800 transition-all hover:scale-[1.03] hover:shadow-2xl hover:z-10"
+ >
+ <v-icon icon="mdi-account-cancel" color="amber" size="40"></v-icon>
+ <div>
+ <div class="font-bold text-xl text-gray-900 dark:text-white">
+ {{ $t("tutorial.banishment") }}
</div>
- <div class="text-gray-500">
- Automated network crawling and page archiving tools for offline browsing.
+ <div class="text-gray-900 dark:text-white">
+ {{ $t("tutorial.banishment_desc") }}
</div>
</div>
</div>
@@ -605,11 +584,10 @@
<v-icon icon="mdi-keyboard-outline" color="red" size="40"></v-icon>
<div>
<div class="font-bold text-xl text-gray-900 dark:text-white">
- Command Palette + Keybindings
+ {{ $t("tutorial.palette") }}
</div>
- <div class="text-gray-500">
- Navigate the entire application and customize your workflow with instant
- shortcuts.
+ <div class="text-gray-900 dark:text-white">
+ {{ $t("tutorial.palette_desc_page") }}
</div>
</div>
</div>
@@ -618,310 +596,114 @@
>
<v-icon icon="mdi-translate" color="cyan" size="40"></v-icon>
<div>
- <div class="font-bold text-xl text-gray-900 dark:text-white">i18n Support</div>
- <div class="text-gray-500">
- Full internationalization support for English, German, and Russian languages.
+ <div class="font-bold text-xl text-gray-900 dark:text-white">
+ {{ $t("tutorial.i18n") }}
+ </div>
+ <div class="text-gray-900 dark:text-white">
+ {{ $t("tutorial.i18n_desc_page") }}
</div>
</div>
</div>
</div>
+ <div
+ class="w-full flex justify-end items-center gap-2 mt-8 px-6 text-gray-400 dark:text-zinc-500"
+ >
+ <v-icon icon="mdi-plus" size="24"></v-icon>
+ <span class="text-base font-bold uppercase tracking-widest">{{
+ $t("tutorial.more_features")
+ }}</span>
+ </div>
</div>
<!-- Step 2: Add Interface -->
- <div v-else-if="currentStep === 2" key="step2" class="space-y-8 py-10">
- <div class="text-center space-y-4">
- <h2 class="text-4xl font-black text-gray-900 dark:text-white">Connect to the Mesh</h2>
- <p class="text-xl text-gray-600 dark:text-zinc-400 max-w-2xl mx-auto">
- To send messages and make calls, you need to connect to a Reticulum interface.
+ <div v-else-if="currentStep === 2" key="step2" class="space-y-6 py-8">
+ <div class="text-center space-y-2">
+ <h2 class="text-3xl font-black text-gray-900 dark:text-white">
+ {{ $t("tutorial.connect") }}
+ </h2>
+ <p class="text-lg text-gray-600 dark:text-zinc-400 max-w-2xl mx-auto">
+ {{ $t("tutorial.connect_desc_page") }}
</p>
</div>
- <div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
+ <div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Community Interfaces -->
<div
- class="bg-gray-50 dark:bg-zinc-900 rounded-[2rem] p-8 border border-gray-100 dark:border-zinc-800"
+ class="bg-gray-50 dark:bg-zinc-900 rounded-[1.5rem] p-5 border border-gray-100 dark:border-zinc-800"
>
- <div class="flex items-center gap-3 mb-6">
- <v-icon icon="mdi-web" color="blue" size="28"></v-icon>
- <span class="text-xl font-bold text-gray-900 dark:text-white"
- >Suggested Public Relays</span
- >
+ <div class="flex items-center gap-2 mb-5">
+ <v-icon icon="mdi-web" color="blue" size="26"></v-icon>
+ <span class="text-lg font-bold text-gray-900 dark:text-white">{{
+ $t("tutorial.suggested_relays")
+ }}</span>
</div>
- <div class="space-y-3 max-h-[400px] overflow-y-auto pr-4 custom-scrollbar">
+ <div class="space-y-3 max-h-[320px] overflow-y-auto pr-3 custom-scrollbar">
<div
v-for="iface in communityInterfaces"
:key="iface.name"
- class="flex items-center justify-between p-4 bg-white dark:bg-zinc-800 rounded-2xl border border-gray-100 dark:border-zinc-700 hover:border-blue-400 transition-all cursor-pointer group shadow-sm hover:shadow-md"
+ class="flex items-center justify-between p-3 bg-white dark:bg-zinc-800 rounded-xl border border-gray-100 dark:border-zinc-700 hover:border-blue-400 transition-all cursor-pointer"
@click="selectCommunityInterface(iface)"
>
<div class="flex flex-col">
- <span
- class="font-bold text-gray-900 dark:text-white text-lg group-hover:text-blue-500 transition-colors"
- >{{ iface.name }}</span
- >
- <span class="text-sm text-gray-500 font-mono"
+ <span class="font-bold text-gray-900 dark:text-white text-base">
+ {{ iface.name }}
+ </span>
+ <span class="text-xs text-gray-500 font-mono"
>{{ iface.target_host }}:{{ iface.target_port }}</span
>
</div>
- <div class="flex items-center gap-4">
+ <div class="flex items-center gap-2">
<span
v-if="iface.online"
- class="flex items-center gap-1.5 text-[10px] font-bold text-green-500 uppercase tracking-widest"
+ class="flex items-center gap-1.5 text-[9px] font-bold text-green-500 uppercase tracking-[0.2em]"
>
<span class="w-2 h-2 rounded-full bg-green-500 animate-pulse"></span>
- Online
+ {{ $t("tutorial.online") }}
</span>
- <v-btn
- variant="flat"
- color="#3b82f6"
- rounded="xl"
- class="font-black px-6 text-white"
+ <button
+ type="button"
+ class="px-4 py-1 text-[11px] rounded-xl bg-blue-600 hover:bg-blue-500 text-white font-semibold shadow-sm transition-all"
@click.stop="selectCommunityInterface(iface)"
- >Use</v-btn
>
+ {{ $t("tutorial.use") }}
+ </button>
</div>
</div>
- <div v-if="loadingInterfaces" class="flex justify-center py-10">
- <v-progress-circular indeterminate color="blue" size="40"></v-progress-circular>
- </div>
- </div>
- </div>
-
- <div v-if="!showCustomForm" class="flex flex-col justify-center space-y-8">
- <div class="relative py-4">
- <div class="absolute inset-0 flex items-center">
- <div class="w-full border-t border-gray-200 dark:border-zinc-800"></div>
- </div>
- <div
- class="relative flex justify-center text-sm font-black uppercase tracking-widest"
- >
- <span class="bg-white dark:bg-zinc-950 px-4 text-gray-400">Custom Setup</span>
+ <div v-if="loadingInterfaces" class="flex justify-center py-4">
+ <v-progress-circular indeterminate color="blue" size="32"></v-progress-circular>
</div>
</div>
-
- <div class="space-y-4">
- <p class="text-gray-500 text-center px-6 text-lg">
- Already have a private relay or hardware RNode? Add it manually to connect to
- your own mesh.
- </p>
- <v-btn
- block
- variant="outlined"
- color="blue"
- rounded="2xl"
- class="font-black h-20 text-xl border-2 dark:text-blue-400"
- prepend-icon="mdi-plus"
- @click="showCustomInterfacePrompt"
- >
- Manual Configuration
- </v-btn>
- </div>
</div>
- <!-- Custom Interface Form (Page Mode) -->
<div
- v-else
- class="space-y-6 animate-in fade-in slide-in-from-right-4 duration-500 bg-gray-50 dark:bg-zinc-900 rounded-[2rem] p-8 border border-gray-100 dark:border-zinc-800"
+ class="flex flex-col justify-center gap-4 text-sm text-gray-900 dark:text-white bg-gray-50 dark:bg-zinc-900 rounded-[1.5rem] p-5 border border-gray-100 dark:border-zinc-800"
>
- <div class="flex items-center justify-between">
- <h3 class="text-2xl font-bold text-gray-900 dark:text-white">Custom Interface</h3>
- <v-btn variant="text" color="gray" @click="showCustomForm = false"
- >Back to suggested</v-btn
- >
- </div>
-
- <div class="space-y-5">
- <v-select
- v-model="newInterface.type"
- :items="interfaceTypes"
- label="Interface Type"
- variant="outlined"
- density="comfortable"
- rounded="xl"
- class="bg-white dark:bg-zinc-800"
- bg-color="white"
- base-color="gray"
- color="blue"
- persistent-placeholder
- :menu-props="{ attach: true }"
- ></v-select>
-
- <v-text-field
- v-model="newInterface.name"
- label="Interface Name"
- placeholder="e.g. My Relay"
- variant="outlined"
- density="comfortable"
- rounded="xl"
- class="bg-white dark:bg-zinc-800"
- bg-color="white"
- base-color="gray"
- color="blue"
- persistent-placeholder
- ></v-text-field>
-
- <!-- TCP Client Fields -->
- <template v-if="newInterface.type === 'TCPClientInterface'">
- <div class="grid grid-cols-2 gap-4">
- <v-text-field
- v-model="newInterface.target_host"
- label="Target Host"
- placeholder="e.g. 1.2.3.4"
- variant="outlined"
- density="comfortable"
- rounded="xl"
- class="bg-white dark:bg-zinc-800"
- bg-color="white"
- base-color="gray"
- color="blue"
- persistent-placeholder
- ></v-text-field>
- <v-text-field
- v-model="newInterface.target_port"
- label="Target Port"
- type="number"
- variant="outlined"
- density="comfortable"
- rounded="xl"
- class="bg-white dark:bg-zinc-800"
- bg-color="white"
- base-color="gray"
- color="blue"
- persistent-placeholder
- ></v-text-field>
- </div>
- </template>
-
- <!-- Server/UDP Fields -->
- <template
- v-if="
- newInterface.type === 'TCPServerInterface' ||
- newInterface.type === 'UDPInterface'
- "
- >
- <div class="grid grid-cols-2 gap-4">
- <v-text-field
- v-model="newInterface.listen_ip"
- label="Listen IP"
- variant="outlined"
- density="comfortable"
- rounded="xl"
- class="bg-white dark:bg-zinc-800"
- bg-color="white"
- base-color="gray"
- color="blue"
- persistent-placeholder
- ></v-text-field>
- <v-text-field
- v-model="newInterface.listen_port"
- label="Listen Port"
- type="number"
- variant="outlined"
- density="comfortable"
- rounded="xl"
- class="bg-white dark:bg-zinc-800"
- bg-color="white"
- base-color="gray"
- color="blue"
- persistent-placeholder
- ></v-text-field>
- </div>
- </template>
-
- <!-- RNode/Serial/KISS Fields -->
- <template
- v-if="
- [
- 'RNodeInterface',
- 'RNodeMultiInterface',
- 'SerialInterface',
- 'KISSInterface',
- 'AX25KISSInterface',
- ].includes(newInterface.type)
- "
- >
- <v-select
- v-model="newInterface.port"
- :items="comports"
- item-title="device"
- item-value="device"
- label="Serial Port"
- variant="outlined"
- density="comfortable"
- rounded="xl"
- class="bg-white dark:bg-zinc-800"
- bg-color="white"
- base-color="gray"
- color="blue"
- persistent-placeholder
- :menu-props="{ attach: true }"
- >
- <template #item="{ props, item }">
- <v-list-item v-bind="props" :subtitle="item.raw.product"></v-list-item>
- </template>
- </v-select>
- <v-text-field
- v-if="
- ['SerialInterface', 'KISSInterface', 'AX25KISSInterface'].includes(
- newInterface.type
- )
- "
- v-model="newInterface.speed"
- label="Baud Rate"
- type="number"
- variant="outlined"
- density="comfortable"
- rounded="xl"
- class="bg-white dark:bg-zinc-800"
- bg-color="white"
- base-color="gray"
- color="blue"
- persistent-placeholder
- ></v-text-field>
- </template>
-
- <!-- Pipe Interface Fields -->
- <template v-if="newInterface.type === 'PipeInterface'">
- <v-text-field
- v-model="newInterface.command"
- label="Command"
- placeholder="e.g. netcat -l 5757"
- variant="outlined"
- density="comfortable"
- rounded="xl"
- class="bg-white dark:bg-zinc-800"
- bg-color="white"
- base-color="gray"
- color="blue"
- persistent-placeholder
- ></v-text-field>
- </template>
-
- <v-btn
- block
- color="blue"
- variant="flat"
- rounded="xl"
- class="h-16 text-lg font-bold text-white mt-4"
- @click="addCustomInterface"
- >
- Add & Connect
- </v-btn>
- <p class="text-sm text-center text-gray-500">
- Additional configuration like frequencies, encryption, and modes can be adjusted
- in the full Interface settings later.
+ <div class="text-center">
+ <p class="text-base font-bold text-gray-900 dark:text-white">
+ {{ $t("tutorial.custom_interfaces") }}
+ </p>
+ <p class="mt-2">
+ {{ $t("tutorial.custom_interfaces_desc_page") }}
</p>
</div>
+ <button
+ type="button"
+ class="px-4 py-2 text-[11px] rounded-xl border border-gray-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-gray-700 dark:text-zinc-300 font-semibold shadow-sm transition-all hover:bg-gray-50 dark:hover:bg-zinc-700 hover:border-blue-400 dark:hover:border-blue-500"
+ @click="gotoAddInterface"
+ >
+ {{ $t("tutorial.open_interfaces") }}
+ </button>
</div>
</div>
</div>
-
<!-- Step 3: Documentation & Tools -->
<div v-else-if="currentStep === 3" key="step3" class="space-y-8 py-10">
<div class="text-center space-y-4">
- <h2 class="text-4xl font-black text-gray-900 dark:text-white">Learn & Create</h2>
+ <h2 class="text-4xl font-black text-gray-900 dark:text-white">
+ {{ $t("tutorial.learn_create") }}
+ </h2>
<p class="text-xl text-gray-600 dark:text-zinc-400 max-w-2xl mx-auto">
- Discover how to use MeshChatX to its full potential and create your own content.
+ {{ $t("tutorial.learn_create_desc_page") }}
</p>
</div>
@@ -932,32 +714,26 @@
<v-icon icon="mdi-book-open-variant" color="blue" size="64"></v-icon>
<div>
<div class="font-bold text-2xl text-gray-900 dark:text-white mb-2">
- Documentation
+ {{ $t("tutorial.documentation") }}
</div>
- <p class="text-gray-500 mb-6">
- Comprehensive guides for MeshChatX and the underlying Reticulum Network Stack.
+ <p class="text-gray-900 dark:text-white mb-6">
+ {{ $t("tutorial.documentation_desc_page") }}
</p>
<div class="flex flex-col gap-3">
- <v-btn
- block
- variant="flat"
- color="blue"
- class="h-12 font-bold text-white"
- rounded="xl"
+ <a
href="/meshchatx-docs/index.html"
target="_blank"
- >Read MeshChatX Docs</v-btn
+ class="h-12 rounded-xl bg-blue-600 hover:bg-blue-500 text-white font-semibold shadow-sm transition-all inline-flex items-center justify-center px-6"
>
- <v-btn
- block
- variant="outlined"
- color="purple"
- class="h-12 font-bold"
- rounded="xl"
- href="https://reticulum.network/manual/"
+ {{ $t("tutorial.read_meshchatx_docs") }}
+ </a>
+ <a
+ href="/reticulum-docs/index.html"
target="_blank"
- >Reticulum Network Manual</v-btn
+ class="h-12 rounded-xl border border-gray-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-gray-700 dark:text-zinc-300 font-semibold shadow-sm transition-all hover:bg-gray-50 dark:hover:bg-zinc-700 hover:border-blue-400 dark:hover:border-blue-500 inline-flex items-center justify-center px-6"
>
+ {{ $t("tutorial.reticulum_manual") }}
+ </a>
</div>
</div>
</div>
@@ -968,21 +744,80 @@
<v-icon icon="mdi-file-document-edit-outline" color="orange" size="64"></v-icon>
<div>
<div class="font-bold text-2xl text-gray-900 dark:text-white mb-2">
- Micron Editor
+ {{ $t("tutorial.micron_editor") }}
</div>
- <p class="text-gray-500 mb-6">
- Learn how to create mesh-native pages and interactive content using the Micron
- markup language.
+ <p class="text-gray-900 dark:text-white mb-6">
+ {{ $t("tutorial.micron_editor_desc_page") }}
</p>
- <v-btn
- block
- variant="flat"
- color="orange"
- class="h-12 font-bold text-white"
- rounded="xl"
- @click="$router.push({ name: 'micron-editor' })"
- >Open Micron Editor</v-btn
+ <button
+ type="button"
+ class="w-full h-12 rounded-xl bg-orange-600 hover:bg-orange-500 text-white font-semibold shadow-sm transition-all"
+ @click="gotoRoute('micron-editor')"
>
+ {{ $t("tutorial.open_micron_editor") }}
+ </button>
+ </div>
+ </div>
+ </div>
+
+ <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6">
+ <div
+ class="flex flex-col items-center gap-4 p-6 rounded-3xl bg-gray-50 dark:bg-zinc-900 text-center border border-gray-100 dark:border-zinc-800 cursor-pointer hover:border-blue-500 transition-all hover:scale-[1.02]"
+ @click="gotoRoute('nomadnetwork')"
+ >
+ <v-icon icon="mdi-earth" color="purple" size="40"></v-icon>
+ <div>
+ <div class="font-bold text-gray-900 dark:text-white">
+ {{ $t("tutorial.paper_messages") }}
+ </div>
+ <div class="text-sm text-gray-900 dark:text-white mt-1">
+ {{ $t("tutorial.paper_messages_desc") }}
+ </div>
+ </div>
+ </div>
+
+ <div
+ class="flex flex-col items-center gap-4 p-6 rounded-3xl bg-gray-50 dark:bg-zinc-900 text-center border border-gray-100 dark:border-zinc-800 cursor-pointer hover:border-blue-500 transition-all hover:scale-[1.02]"
+ @click="gotoRoute('messages')"
+ >
+ <v-icon icon="mdi-message-text-outline" color="green" size="40"></v-icon>
+ <div>
+ <div class="font-bold text-gray-900 dark:text-white">
+ {{ $t("tutorial.send_messages") }}
+ </div>
+ <div class="text-sm text-gray-900 dark:text-white mt-1">
+ {{ $t("tutorial.send_messages_desc") }}
+ </div>
+ </div>
+ </div>
+
+ <div
+ class="flex flex-col items-center gap-4 p-6 rounded-3xl bg-gray-50 dark:bg-zinc-900 text-center border border-gray-100 dark:border-zinc-800 cursor-pointer hover:border-blue-500 transition-all hover:scale-[1.02]"
+ @click="gotoRoute('network-visualiser')"
+ >
+ <v-icon icon="mdi-hub" color="teal" size="40"></v-icon>
+ <div>
+ <div class="font-bold text-gray-900 dark:text-white">
+ {{ $t("tutorial.explore_nodes") }}
+ </div>
+ <div class="text-sm text-gray-900 dark:text-white mt-1">
+ {{ $t("tutorial.explore_nodes_desc") }}
+ </div>
+ </div>
+ </div>
+
+ <div
+ class="flex flex-col items-center gap-4 p-6 rounded-3xl bg-gray-50 dark:bg-zinc-900 text-center border border-gray-100 dark:border-zinc-800 cursor-pointer hover:border-blue-500 transition-all hover:scale-[1.02]"
+ @click="gotoRoute('call')"
+ >
+ <v-icon icon="mdi-phone-in-talk-outline" color="red" size="40"></v-icon>
+ <div>
+ <div class="font-bold text-gray-900 dark:text-white">
+ {{ $t("tutorial.voice_calls") }}
+ </div>
+ <div class="text-sm text-gray-900 dark:text-white mt-1">
+ {{ $t("tutorial.voice_calls_desc") }}
+ </div>
</div>
</div>
</div>
@@ -999,10 +834,11 @@
<div class="absolute inset-0 bg-green-500/20 rounded-full animate-ping opacity-20"></div>
</div>
<div class="space-y-4">
- <h2 class="text-5xl font-black text-gray-900 dark:text-white">Ready to Roll!</h2>
+ <h2 class="text-5xl font-black text-gray-900 dark:text-white">
+ {{ $t("tutorial.ready") }}
+ </h2>
<p class="text-xl text-gray-600 dark:text-zinc-400 max-w-2xl mx-auto">
- MeshChatX is now configured. You need to restart the application to finalize the
- connection.
+ {{ $t("tutorial.ready_desc_page") }}
</p>
</div>
<div
@@ -1010,10 +846,9 @@
>
<v-icon icon="mdi-information-outline" size="32" class="shrink-0"></v-icon>
<div class="space-y-1">
- <div class="font-bold text-lg">Restart Required</div>
+ <div class="font-bold text-lg">{{ $t("tutorial.restart_required") }}</div>
<div class="opacity-90">
- If you're running in Docker, ensure your container auto-restarts. Native apps will
- relaunch automatically.
+ {{ $t("tutorial.restart_desc_page") }}
</div>
</div>
</div>
@@ -1022,45 +857,43 @@
<!-- Navigation Buttons (Page Mode) -->
<div class="flex justify-between items-center mt-12 border-t dark:border-zinc-900 pt-8">
- <v-btn
+ <button
v-if="currentStep > 1 && currentStep < totalSteps"
- variant="text"
- color="gray"
- class="font-black h-12 px-8 text-gray-700 dark:text-zinc-300"
+ type="button"
+ class="px-8 h-12 rounded-xl border border-gray-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-gray-700 dark:text-zinc-300 font-semibold text-sm shadow-sm transition-all hover:bg-gray-50 dark:hover:bg-zinc-700 hover:border-blue-400 dark:hover:border-blue-500"
@click="currentStep--"
- >Back</v-btn
>
+ {{ $t("tutorial.back") }}
+ </button>
<div v-else></div>
<div class="flex gap-4">
- <v-btn
+ <button
v-if="currentStep < totalSteps"
- variant="text"
- color="gray"
- class="font-black h-12 px-8 opacity-50 hover:opacity-100 text-gray-700 dark:text-zinc-300"
+ type="button"
+ class="px-8 h-12 rounded-xl border border-gray-300 dark:border-zinc-700 bg-white dark:bg-zinc-800 text-gray-700 dark:text-zinc-300 font-semibold text-sm shadow-sm transition-all opacity-50 hover:opacity-100 hover:bg-gray-50 dark:hover:bg-zinc-700"
@click="skipTutorial"
- >Skip Setup</v-btn
>
+ {{ $t("tutorial.skip_setup") }}
+ </button>
- <v-btn
+ <button
v-if="currentStep < totalSteps"
- variant="flat"
- color="#3b82f6"
- class="px-12 font-black tracking-wide h-14 text-lg text-white"
- rounded="2xl"
+ type="button"
+ class="px-12 h-14 text-lg rounded-xl bg-blue-600 hover:bg-blue-500 text-white font-semibold shadow-sm transition-all"
@click="nextStep"
- >Continue</v-btn
>
+ {{ $t("tutorial.continue") }}
+ </button>
- <v-btn
+ <button
v-else
- variant="flat"
- color="#22c55e"
- class="px-12 font-black tracking-wide h-14 text-lg text-white"
- rounded="2xl"
+ type="button"
+ class="px-12 h-14 text-lg rounded-xl bg-emerald-600 hover:bg-emerald-500 text-white font-semibold shadow-sm transition-all"
@click="finishAndRestart"
- >Restart & Start Chatting</v-btn
>
+ {{ $t("tutorial.restart_start") }}
+ </button>
</div>
</div>
</div>
@@ -1074,9 +907,15 @@ import ToastUtils from "../js/ToastUtils";
import DialogUtils from "../js/DialogUtils";
import ElectronUtils from "../js/ElectronUtils";
import GlobalState from "../js/GlobalState";
+import LanguageSelector from "./LanguageSelector.vue";
+import MaterialDesignIcon from "./MaterialDesignIcon.vue";
export default {
name: "TutorialModal",
+ components: {
+ LanguageSelector,
+ MaterialDesignIcon,
+ },
data() {
return {
visible: false,
@@ -1085,32 +924,7 @@ export default {
logoUrl,
communityInterfaces: [],
loadingInterfaces: false,
- showCustomForm: false,
- comports: [],
- newInterface: {
- name: "",
- type: "TCPClientInterface",
- target_host: "",
- target_port: 4242,
- listen_ip: "0.0.0.0",
- listen_port: 4242,
- device: "",
- port: "",
- speed: 115200,
- },
- interfaceTypes: [
- { title: "TCP Client", value: "TCPClientInterface" },
- { title: "TCP Server", value: "TCPServerInterface" },
- { title: "UDP Interface", value: "UDPInterface" },
- { title: "RNode Interface", value: "RNodeInterface" },
- { title: "RNode Multi", value: "RNodeMultiInterface" },
- { title: "Serial Interface", value: "SerialInterface" },
- { title: "KISS Interface", value: "KISSInterface" },
- { title: "AX.25 KISS", value: "AX25KISSInterface" },
- { title: "I2P Interface", value: "I2PInterface" },
- { title: "Auto Interface", value: "AutoInterface" },
- { title: "Pipe Interface", value: "PipeInterface" },
- ],
+ interfaceAddedViaTutorial: false,
};
},
computed: {
@@ -1120,28 +934,44 @@ export default {
isMobile() {
return window.innerWidth < 640;
},
+ config() {
+ return GlobalState.config;
+ },
},
mounted() {
if (this.isPage) {
this.loadCommunityInterfaces();
- this.loadComports();
}
},
methods: {
- async show() {
- this.visible = true;
- this.currentStep = 1;
- await this.loadCommunityInterfaces();
- await this.loadComports();
+ async toggleTheme() {
+ const newTheme = this.config.theme === "dark" ? "light" : "dark";
+ try {
+ await window.axios.patch("/api/v1/config", {
+ theme: newTheme,
+ });
+ GlobalState.config.theme = newTheme;
+ } catch (e) {
+ console.error("Failed to update theme:", e);
+ }
},
- async loadComports() {
+ async onLanguageChange(langCode) {
try {
- const response = await window.axios.get("/api/v1/comports");
- this.comports = response.data.comports;
+ await window.axios.patch("/api/v1/config", {
+ language: langCode,
+ });
+ this.$i18n.locale = langCode;
+ GlobalState.config.language = langCode;
} catch (e) {
- console.error("Failed to load comports:", e);
+ console.error("Failed to update language:", e);
}
},
+ async show() {
+ this.visible = true;
+ this.currentStep = 1;
+ this.interfaceAddedViaTutorial = false;
+ await this.loadCommunityInterfaces();
+ },
async loadCommunityInterfaces() {
this.loadingInterfaces = true;
try {
@@ -1164,6 +994,8 @@ export default {
});
ToastUtils.success(`Added interface: ${iface.name}`);
+ this.interfaceAddedViaTutorial = true;
+
// track change
GlobalState.hasPendingInterfaceChanges = true;
GlobalState.modifiedInterfaceNames.add(iface.name);
@@ -1174,63 +1006,20 @@ export default {
ToastUtils.error(e.response?.data?.message || "Failed to add interface");
}
},
- showCustomInterfacePrompt() {
- this.showCustomForm = true;
+ gotoAddInterface() {
+ if (!this.isPage) {
+ this.visible = false;
+ }
+ if (this.$router) {
+ this.$router.push({ path: "/interfaces/add" });
+ }
},
- async addCustomInterface() {
- if (!this.newInterface.name) {
- ToastUtils.error("Please enter an interface name");
- return;
+ gotoRoute(routeName) {
+ if (!this.isPage) {
+ this.visible = false;
}
-
- try {
- const payload = {
- name: this.newInterface.name,
- type: this.newInterface.type,
- enabled: true,
- };
-
- if (this.newInterface.type === "TCPClientInterface") {
- payload.target_host = this.newInterface.target_host;
- payload.target_port = parseInt(this.newInterface.target_port);
- } else if (
- this.newInterface.type === "TCPServerInterface" ||
- this.newInterface.type === "UDPInterface"
- ) {
- payload.listen_ip = this.newInterface.listen_ip;
- payload.listen_port = parseInt(this.newInterface.listen_port);
- if (this.newInterface.type === "UDPInterface") {
- payload.forward_ip = "255.255.255.255";
- payload.forward_port = parseInt(this.newInterface.listen_port);
- }
- } else if (
- [
- "RNodeInterface",
- "RNodeMultiInterface",
- "SerialInterface",
- "KISSInterface",
- "AX25KISSInterface",
- ].includes(this.newInterface.type)
- ) {
- payload.port = this.newInterface.port;
- if (["SerialInterface", "KISSInterface", "AX25KISSInterface"].includes(this.newInterface.type)) {
- payload.speed = parseInt(this.newInterface.speed);
- }
- } else if (this.newInterface.type === "PipeInterface") {
- payload.command = this.newInterface.command;
- }
-
- await window.axios.post("/api/v1/reticulum/interfaces/add", payload);
- ToastUtils.success(`Added interface: ${this.newInterface.name}`);
-
- // track change
- GlobalState.hasPendingInterfaceChanges = true;
- GlobalState.modifiedInterfaceNames.add(this.newInterface.name);
-
- this.nextStep();
- } catch (e) {
- console.error(e);
- ToastUtils.error(e.response?.data?.message || "Failed to add interface");
+ if (this.$router) {
+ this.$router.push({ name: routeName });
}
},
nextStep() {
@@ -1239,11 +1028,7 @@ export default {
}
},
async skipTutorial() {
- if (
- await DialogUtils.confirm(
- "Are you sure you want to skip the setup? You'll need to manually add interfaces later."
- )
- ) {
+ if (await DialogUtils.confirm(this.$t("tutorial.skip_confirm"))) {
await this.markSeen();
this.visible = false;
}
@@ -1260,7 +1045,9 @@ export default {
if (ElectronUtils.isElectron()) {
ElectronUtils.relaunch();
} else {
- ToastUtils.info("Restart the application/container to apply changes.");
+ if (this.interfaceAddedViaTutorial) {
+ ToastUtils.info("Restart the application/container to apply changes.");
+ }
this.visible = false;
}
},
diff --git a/meshchatx/src/frontend/components/archives/ArchivesPage.vue b/meshchatx/src/frontend/components/archives/ArchivesPage.vue
index c97ed164..9c4d9151 100644
--- a/meshchatx/src/frontend/components/archives/ArchivesPage.vue
+++ b/meshchatx/src/frontend/components/archives/ArchivesPage.vue
@@ -3,7 +3,7 @@
<div class="flex h-full overflow-hidden bg-white dark:bg-zinc-950">
<!-- Sidebar 1: Nodes -->
<ArchiveSidebar
- v-if="!isSidebarHidden"
+ v-if="!isSidebar1Hidden"
class="w-full sm:w-64 border-r border-gray-200 dark:border-zinc-800 shrink-0"
:class="{ 'hidden sm:flex': selectedNodeHash }"
:nodes="groupedArchives"
@@ -15,7 +15,7 @@
<!-- Sidebar 2: Snapshots -->
<div
- v-if="selectedNode"
+ v-if="selectedNode && !isSidebar2Hidden"
class="w-full sm:w-80 border-r border-gray-200 dark:border-zinc-800 flex flex-col shrink-0 bg-gray-50 dark:bg-zinc-900/50"
:class="{ 'hidden sm:flex': viewingArchive }"
>
@@ -140,6 +140,23 @@
</div>
<div class="flex items-center gap-1">
+ <button
+ class="p-2 hover:bg-zinc-800 rounded transition-colors hidden sm:block"
+ :class="{ 'text-blue-400': !isSidebar1Hidden, 'text-zinc-600': isSidebar1Hidden }"
+ :title="isSidebar1Hidden ? 'Show Nodes' : 'Hide Nodes'"
+ @click="isSidebar1Hidden = !isSidebar1Hidden"
+ >
+ <MaterialDesignIcon icon-name="page-layout-sidebar-left" class="size-4" />
+ </button>
+ <button
+ class="p-2 hover:bg-zinc-800 rounded transition-colors hidden sm:block"
+ :class="{ 'text-blue-400': !isSidebar2Hidden, 'text-zinc-600': isSidebar2Hidden }"
+ :title="isSidebar2Hidden ? 'Show Snapshots' : 'Hide Snapshots'"
+ @click="isSidebar2Hidden = !isSidebar2Hidden"
+ >
+ <MaterialDesignIcon icon-name="view-list" class="size-4" />
+ </button>
+ <div class="hidden sm:block w-px h-6 bg-zinc-800 mx-1"></div>
<button
class="p-2 hover:bg-zinc-800 rounded transition-colors text-blue-400 flex items-center gap-2"
@click="openInNomadnet(viewingArchive)"
@@ -199,7 +216,8 @@ export default {
muParser: new MicronParser(),
selectedNodeHash: null,
viewingArchive: null,
- isSidebarHidden: false,
+ isSidebar1Hidden: false,
+ isSidebar2Hidden: false,
renderedContent: "",
searchQuery: "",
selectedArchives: [],
@@ -257,6 +275,8 @@ export default {
}, 10);
} else {
this.renderedContent = "";
+ this.isSidebar1Hidden = false;
+ this.isSidebar2Hidden = false;
}
},
},
diff --git a/meshchatx/src/frontend/components/call/CallOverlay.vue b/meshchatx/src/frontend/components/call/CallOverlay.vue
index 3af8e67f..537ef2d2 100644
--- a/meshchatx/src/frontend/components/call/CallOverlay.vue
+++ b/meshchatx/src/frontend/components/call/CallOverlay.vue
@@ -1,6 +1,6 @@
<template>
<div
- v-if="activeCall"
+ v-if="activeCall || initiationStatus || isEnded || wasDeclined"
class="fixed bottom-4 right-4 z-[100] w-80 bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl border border-gray-200 dark:border-zinc-800 overflow-hidden transition-all duration-300"
:class="{ 'ring-2 ring-red-500 ring-opacity-50': isEnded || wasDeclined }"
>
@@ -25,11 +25,13 @@
? $t("call.call_declined")
: isEnded
? $t("call.call_ended")
- : activeCall.is_voicemail
+ : activeCall && activeCall.is_voicemail
? $t("call.recording_voicemail")
- : activeCall.status === 6
+ : activeCall && activeCall.status === 6
? $t("call.active_call")
- : $t("call.call_status")
+ : initiationStatus
+ ? $t("call.initiation")
+ : $t("call.call_status")
}}
</span>
<MaterialDesignIcon
@@ -65,27 +67,35 @@
"
>
<LxmfUserIcon
- :custom-image="activeCall.custom_image"
- :icon-name="activeCall.remote_icon ? activeCall.remote_icon.icon_name : ''"
- :icon-foreground-colour="activeCall.remote_icon ? activeCall.remote_icon.foreground_colour : ''"
- :icon-background-colour="activeCall.remote_icon ? activeCall.remote_icon.background_colour : ''"
+ :custom-image="activeCall ? activeCall.custom_image : null"
+ :icon-name="activeCall && activeCall.remote_icon ? activeCall.remote_icon.icon_name : ''"
+ :icon-foreground-colour="
+ activeCall && activeCall.remote_icon ? activeCall.remote_icon.foreground_colour : ''
+ "
+ :icon-background-colour="
+ activeCall && activeCall.remote_icon ? activeCall.remote_icon.background_colour : ''
+ "
icon-class="size-14"
/>
</div>
<div class="text-center w-full min-w-0">
<div class="font-bold text-gray-900 dark:text-white truncate px-2">
- {{ activeCall.remote_identity_name || $t("call.unknown") }}
+ {{
+ (activeCall ? activeCall.remote_identity_name : initiationTargetName) || $t("call.unknown")
+ }}
</div>
<div
- v-if="activeCall.is_contact"
+ v-if="activeCall ? activeCall.is_contact : !!initiationTargetName"
class="text-[10px] text-blue-600 dark:text-blue-400 font-medium mt-0.5"
>
In contacts
</div>
<div class="text-[10px] text-gray-500 dark:text-zinc-500 font-mono truncate px-4">
{{
- activeCall.remote_identity_hash
- ? formatDestinationHash(activeCall.remote_identity_hash)
+ (activeCall ? activeCall.remote_identity_hash : initiationTargetHash)
+ ? formatDestinationHash(
+ activeCall ? activeCall.remote_identity_hash : initiationTargetHash
+ )
: ""
}}
</div>
@@ -242,18 +252,24 @@
>
<div class="flex items-center space-x-2 overflow-hidden mr-2 min-w-0">
<LxmfUserIcon
- :custom-image="activeCall.custom_image"
- :icon-name="activeCall.remote_icon ? activeCall.remote_icon.icon_name : ''"
- :icon-foreground-colour="activeCall.remote_icon ? activeCall.remote_icon.foreground_colour : ''"
- :icon-background-colour="activeCall.remote_icon ? activeCall.remote_icon.background_colour : ''"
+ :custom-image="activeCall ? activeCall.custom_image : null"
+ :icon-name="activeCall && activeCall.remote_icon ? activeCall.remote_icon.icon_name : ''"
+ :icon-foreground-colour="
+ activeCall && activeCall.remote_icon ? activeCall.remote_icon.foreground_colour : ''
+ "
+ :icon-background-colour="
+ activeCall && activeCall.remote_icon ? activeCall.remote_icon.background_colour : ''
+ "
icon-class="size-6 shrink-0"
/>
<div class="flex flex-col min-w-0">
<span class="text-sm font-medium text-gray-700 dark:text-zinc-200 truncate block">
- {{ activeCall.remote_identity_name || $t("call.unknown") }}
+ {{
+ (activeCall ? activeCall.remote_identity_name : initiationTargetName) || $t("call.unknown")
+ }}
</span>
<span
- v-if="activeCall.status === 6 && elapsedTime"
+ v-if="activeCall && activeCall.status === 6 && elapsedTime"
class="text-[10px] text-gray-500 dark:text-zinc-400 font-mono"
>
{{ elapsedTime }}
@@ -297,7 +313,7 @@ export default {
props: {
activeCall: {
type: Object,
- required: true,
+ default: null,
},
isEnded: {
type: Boolean,
@@ -315,6 +331,14 @@ export default {
type: String,
default: null,
},
+ initiationTargetHash: {
+ type: String,
+ default: null,
+ },
+ initiationTargetName: {
+ type: String,
+ default: null,
+ },
},
emits: ["hangup", "toggle-mic", "toggle-speaker"],
data() {
diff --git a/meshchatx/src/frontend/components/call/CallPage.vue b/meshchatx/src/frontend/components/call/CallPage.vue
index eb10c634..acfa7bb8 100644
--- a/meshchatx/src/frontend/components/call/CallPage.vue
+++ b/meshchatx/src/frontend/components/call/CallPage.vue
@@ -141,16 +141,24 @@
<div class="relative z-10 space-y-1 mb-8">
<h2 class="text-2xl font-bold text-gray-900 dark:text-white truncate max-w-[280px]">
- {{ (activeCall || lastCall)?.remote_identity_name || $t("call.unknown") }}
+ {{
+ (activeCall || lastCall)?.remote_identity_name ||
+ initiationTargetName ||
+ $t("call.unknown")
+ }}
</h2>
<div
- v-if="(activeCall || lastCall)?.remote_identity_hash"
+ v-if="(activeCall || lastCall)?.remote_identity_hash || initiationTargetHash"
class="text-xs font-mono text-gray-400 dark:text-zinc-500 tracking-wider"
>
- {{ formatDestinationHash((activeCall || lastCall).remote_identity_hash) }}
+ {{
+ formatDestinationHash(
+ (activeCall || lastCall)?.remote_identity_hash || initiationTargetHash
+ )
+ }}
</div>
<div
- v-if="(activeCall || lastCall)?.is_contact"
+ v-if="(activeCall || lastCall)?.is_contact || !!initiationTargetName"
class="inline-flex items-center gap-1 px-2 py-0.5 bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 text-[10px] font-bold rounded-full uppercase tracking-wider"
>
<MaterialDesignIcon icon-name="check-decagram" class="size-3" />
@@ -2001,6 +2009,7 @@ export default {
localSpeakerMuted: false,
initiationStatus: null,
initiationTargetHash: null,
+ initiationTargetName: null,
};
},
computed: {
@@ -2202,6 +2211,7 @@ export default {
this.activeCall = newCall;
this.initiationStatus = response.data.initiation_status;
this.initiationTargetHash = response.data.initiation_target_hash;
+ this.initiationTargetName = response.data.initiation_target_name;
if (this.activeCall?.is_voicemail) {
this.wasVoicemail = true;
@@ -2862,6 +2872,9 @@ export default {
// Provide immediate feedback
this.destinationHash = hashToCall;
+ const targetContact = this.contacts.find((c) => c.remote_identity_hash === hashToCall);
+ this.initiationTargetHash = hashToCall;
+ this.initiationTargetName = targetContact ? targetContact.name : null;
this.activeTab = "phone";
this.initiationStatus = "Initiating...";
this.isCallEnded = false;
diff --git a/meshchatx/src/frontend/components/forwarder/ForwarderPage.vue b/meshchatx/src/frontend/components/forwarder/ForwarderPage.vue
index d3c5d953..01f8b192 100644
--- a/meshchatx/src/frontend/components/forwarder/ForwarderPage.vue
+++ b/meshchatx/src/frontend/components/forwarder/ForwarderPage.vue
@@ -2,131 +2,148 @@
<div
class="flex flex-col flex-1 overflow-hidden min-w-0 bg-gradient-to-br from-slate-50 via-slate-100 to-white dark:from-zinc-950 dark:via-zinc-900 dark:to-zinc-900"
>
- <div class="overflow-y-auto space-y-4 p-4 md:p-6 max-w-5xl mx-auto w-full">
- <div class="glass-card space-y-3">
- <div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
- {{ $t("tools.utilities") }}
- </div>
- <div class="text-2xl font-semibold text-gray-900 dark:text-white">{{ $t("forwarder.title") }}</div>
- <div class="text-sm text-gray-600 dark:text-gray-300">
- {{ $t("forwarder.description") }}
- </div>
- </div>
-
- <!-- Add New Rule -->
- <div class="glass-card space-y-4">
- <div class="text-lg font-semibold text-gray-900 dark:text-white">{{ $t("forwarder.add_rule") }}</div>
- <div class="grid gap-4 md:grid-cols-3">
- <div class="space-y-1">
- <label class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{{
- $t("forwarder.name")
- }}</label>
- <input
- v-model="newRule.name"
- type="text"
- :placeholder="$t('forwarder.name_placeholder')"
- class="w-full px-4 py-2 rounded-xl border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 transition-all outline-none"
- />
- </div>
- <div class="space-y-1">
- <label class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{{
- $t("forwarder.forward_to_hash")
- }}</label>
- <input
- v-model="newRule.forward_to_hash"
- type="text"
- :placeholder="$t('forwarder.destination_placeholder')"
- class="w-full px-4 py-2 rounded-xl border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 transition-all outline-none"
- />
+ <div class="flex-1 overflow-y-auto w-full">
+ <div class="space-y-4 p-4 md:p-6 max-w-5xl mx-auto w-full">
+ <div class="glass-card space-y-3">
+ <div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
+ {{ $t("tools.utilities") }}
</div>
- <div class="space-y-1">
- <label class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{{
- $t("forwarder.source_filter")
- }}</label>
- <input
- v-model="newRule.source_filter_hash"
- type="text"
- :placeholder="$t('forwarder.source_filter_placeholder')"
- class="w-full px-4 py-2 rounded-xl border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 transition-all outline-none"
- />
+ <div class="text-2xl font-semibold text-gray-900 dark:text-white">{{ $t("forwarder.title") }}</div>
+ <div class="text-sm text-gray-600 dark:text-gray-300">
+ {{ $t("forwarder.description") }}
</div>
</div>
- <div class="flex justify-end">
- <button
- class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-xl font-medium transition-colors flex items-center gap-2"
- @click="addRule"
- >
- <MaterialDesignIcon icon-name="plus" class="w-5 h-5" />
- {{ $t("forwarder.add_button") }}
- </button>
- </div>
- </div>
- <!-- Rules List -->
- <div class="space-y-4">
- <div class="text-lg font-semibold text-gray-900 dark:text-white">
- {{ $t("forwarder.active_rules") }}
- </div>
- <div v-if="rules.length === 0" class="glass-card text-center py-12 text-gray-500 dark:text-zinc-400">
- {{ $t("forwarder.no_rules") }}
- </div>
- <div v-for="rule in rules" :key="rule.id" class="glass-card flex items-center justify-between gap-4">
- <div class="flex-1 min-w-0">
- <div class="flex items-center gap-2 mb-1">
- <div
- class="px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider"
- :class="
- rule.is_active
- ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
- : 'bg-gray-100 text-gray-700 dark:bg-zinc-800 dark:text-zinc-400'
- "
+ <!-- Add New Rule -->
+ <div class="glass-card space-y-4">
+ <div class="text-lg font-semibold text-gray-900 dark:text-white">
+ {{ $t("forwarder.add_rule") }}
+ </div>
+ <div class="grid gap-4 md:grid-cols-3">
+ <div class="space-y-1">
+ <label
+ class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"
+ >{{ $t("forwarder.name") }}</label
>
- {{ rule.is_active ? $t("forwarder.active") : $t("forwarder.disabled") }}
- </div>
- <span class="text-xs text-gray-500 dark:text-zinc-400">ID: {{ rule.id }}</span>
+ <input
+ v-model="newRule.name"
+ type="text"
+ :placeholder="$t('forwarder.name_placeholder')"
+ class="w-full px-4 py-2 rounded-xl border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 transition-all outline-none"
+ />
</div>
- <div v-if="rule.name" class="text-base font-semibold text-gray-900 dark:text-white mb-1">
- {{ rule.name }}
+ <div class="space-y-1">
+ <label
+ class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"
+ >{{ $t("forwarder.forward_to_hash") }}</label
+ >
+ <input
+ v-model="newRule.forward_to_hash"
+ type="text"
+ :placeholder="$t('forwarder.destination_placeholder')"
+ class="w-full px-4 py-2 rounded-xl border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 transition-all outline-none"
+ />
</div>
<div class="space-y-1">
- <div class="flex items-center gap-2">
- <MaterialDesignIcon icon-name="arrow-right" class="w-4 h-4 text-blue-500 shrink-0" />
- <span class="text-sm font-medium text-gray-900 dark:text-white truncate">
- {{ $t("forwarder.forwarding_to", { hash: rule.forward_to_hash }) }}
- </span>
- </div>
- <div v-if="rule.source_filter_hash" class="flex items-center gap-2">
- <MaterialDesignIcon
- icon-name="filter-variant"
- class="w-4 h-4 text-purple-500 shrink-0"
- />
- <span class="text-sm text-gray-600 dark:text-zinc-300 truncate">
- {{ $t("forwarder.source_filter_display", { hash: rule.source_filter_hash }) }}
- </span>
- </div>
+ <label
+ class="text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider"
+ >{{ $t("forwarder.source_filter") }}</label
+ >
+ <input
+ v-model="newRule.source_filter_hash"
+ type="text"
+ :placeholder="$t('forwarder.source_filter_placeholder')"
+ class="w-full px-4 py-2 rounded-xl border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 text-gray-900 dark:text-white focus:ring-2 focus:ring-blue-500 transition-all outline-none"
+ />
</div>
</div>
- <div class="flex items-center gap-2">
- <button
- class="p-2 hover:bg-gray-100 dark:hover:bg-zinc-800 rounded-lg transition-colors"
- :title="rule.is_active ? $t('forwarder.disabled') : $t('forwarder.active')"
- @click="toggleRule(rule.id)"
- >
- <MaterialDesignIcon
- :icon-name="rule.is_active ? 'toggle-switch' : 'toggle-switch-off'"
- class="w-6 h-6"
- :class="rule.is_active ? 'text-blue-500' : 'text-gray-400'"
- />
- </button>
+ <div class="flex justify-end">
<button
- class="p-2 hover:bg-red-50 dark:hover:bg-red-900/20 text-red-500 rounded-lg transition-colors"
- :title="$t('common.delete')"
- @click="deleteRule(rule.id)"
+ class="px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-xl font-medium transition-colors flex items-center gap-2"
+ @click="addRule"
>
- <MaterialDesignIcon icon-name="delete" class="w-6 h-6" />
+ <MaterialDesignIcon icon-name="plus" class="w-5 h-5" />
+ {{ $t("forwarder.add_button") }}
</button>
</div>
</div>
+
+ <!-- Rules List -->
+ <div class="space-y-4">
+ <div class="text-lg font-semibold text-gray-900 dark:text-white">
+ {{ $t("forwarder.active_rules") }}
+ </div>
+ <div
+ v-if="rules.length === 0"
+ class="glass-card text-center py-12 text-gray-500 dark:text-zinc-400"
+ >
+ {{ $t("forwarder.no_rules") }}
+ </div>
+ <div
+ v-for="rule in rules"
+ :key="rule.id"
+ class="glass-card flex items-center justify-between gap-4"
+ >
+ <div class="flex-1 min-w-0">
+ <div class="flex items-center gap-2 mb-1">
+ <div
+ class="px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider"
+ :class="
+ rule.is_active
+ ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
+ : 'bg-gray-100 text-gray-700 dark:bg-zinc-800 dark:text-zinc-400'
+ "
+ >
+ {{ rule.is_active ? $t("forwarder.active") : $t("forwarder.disabled") }}
+ </div>
+ <span class="text-xs text-gray-500 dark:text-zinc-400">ID: {{ rule.id }}</span>
+ </div>
+ <div v-if="rule.name" class="text-base font-semibold text-gray-900 dark:text-white mb-1">
+ {{ rule.name }}
+ </div>
+ <div class="space-y-1">
+ <div class="flex items-center gap-2">
+ <MaterialDesignIcon
+ icon-name="arrow-right"
+ class="w-4 h-4 text-blue-500 shrink-0"
+ />
+ <span class="text-sm font-medium text-gray-900 dark:text-white truncate">
+ {{ $t("forwarder.forwarding_to", { hash: rule.forward_to_hash }) }}
+ </span>
+ </div>
+ <div v-if="rule.source_filter_hash" class="flex items-center gap-2">
+ <MaterialDesignIcon
+ icon-name="filter-variant"
+ class="w-4 h-4 text-purple-500 shrink-0"
+ />
+ <span class="text-sm text-gray-600 dark:text-zinc-300 truncate">
+ {{ $t("forwarder.source_filter_display", { hash: rule.source_filter_hash }) }}
+ </span>
+ </div>
+ </div>
+ </div>
+ <div class="flex items-center gap-2">
+ <button
+ class="p-2 hover:bg-gray-100 dark:hover:bg-zinc-800 rounded-lg transition-colors"
+ :title="rule.is_active ? $t('forwarder.disabled') : $t('forwarder.active')"
+ @click="toggleRule(rule.id)"
+ >
+ <MaterialDesignIcon
+ :icon-name="rule.is_active ? 'toggle-switch' : 'toggle-switch-off'"
+ class="w-6 h-6"
+ :class="rule.is_active ? 'text-blue-500' : 'text-gray-400'"
+ />
+ </button>
+ <button
+ class="p-2 hover:bg-red-50 dark:hover:bg-red-900/20 text-red-500 rounded-lg transition-colors"
+ :title="$t('common.delete')"
+ @click="deleteRule(rule.id)"
+ >
+ <MaterialDesignIcon icon-name="delete" class="w-6 h-6" />
+ </button>
+ </div>
+ </div>
+ </div>
</div>
</div>
</div>
diff --git a/meshchatx/src/frontend/components/interfaces/AddInterfacePage.vue b/meshchatx/src/frontend/components/interfaces/AddInterfacePage.vue
index 46b53585..2b32841a 100644
--- a/meshchatx/src/frontend/components/interfaces/AddInterfacePage.vue
+++ b/meshchatx/src/frontend/components/interfaces/AddInterfacePage.vue
@@ -141,10 +141,7 @@
</select>
<FormSubLabel>
Need help?
- <a
- class="text-blue-500 underline"
- href="https://reticulum.network/manual/interfaces.html"
- target="_blank"
+ <a class="text-blue-500 underline" href="/reticulum-docs/interfaces.html" target="_blank"
>Reticulum Docs: Configuring Interfaces</a
>
</FormSubLabel>
@@ -982,7 +979,7 @@
This setting requires Transport Mode to be enabled.
<a
class="text-blue-500 underline"
- href="https://reticulum.network/manual/interfaces.html#interface-modes"
+ href="/reticulum-docs/interfaces.html#interface-modes"
target="_blank"
>Reticulum Docs: Interface Modes</a
>
diff --git a/meshchatx/src/frontend/components/interfaces/InterfacesPage.vue b/meshchatx/src/frontend/components/interfaces/InterfacesPage.vue
index 4e71e240..a0519cc0 100644
--- a/meshchatx/src/frontend/components/interfaces/InterfacesPage.vue
+++ b/meshchatx/src/frontend/components/interfaces/InterfacesPage.vue
@@ -2,54 +2,57 @@
<div
class="flex flex-col flex-1 overflow-hidden min-w-0 bg-gradient-to-br from-slate-50 via-slate-100 to-white dark:from-zinc-950 dark:via-zinc-900 dark:to-zinc-900"
>
- <div class="overflow-y-auto p-3 md:p-6 space-y-4 max-w-6xl mx-auto w-full flex-1">
- <div
- v-if="showRestartReminder"
- class="bg-gradient-to-r from-amber-500 to-orange-500 text-white rounded-3xl shadow-xl p-4 flex flex-wrap gap-3 items-center"
- >
- <div class="flex items-center gap-3">
- <MaterialDesignIcon icon-name="alert" class="w-6 h-6" />
- <div>
- <div class="text-lg font-semibold">{{ $t("interfaces.restart_required") }}</div>
- <div class="text-sm">{{ $t("interfaces.restart_description") }}</div>
+ <div class="flex-1 overflow-y-auto w-full">
+ <div class="p-3 md:p-6 space-y-4 max-w-6xl mx-auto w-full flex-1">
+ <div
+ v-if="showRestartReminder"
+ class="bg-gradient-to-r from-amber-500 to-orange-500 text-white rounded-3xl shadow-xl p-4 flex flex-wrap gap-3 items-center"
+ >
+ <div class="flex items-center gap-3">
+ <MaterialDesignIcon icon-name="alert" class="w-6 h-6" />
+ <div>
+ <div class="text-lg font-semibold">{{ $t("interfaces.restart_required") }}</div>
+ <div class="text-sm">{{ $t("interfaces.restart_description") }}</div>
+ </div>
</div>
+ <button
+ v-if="isElectron"
+ type="button"
+ class="ml-auto inline-flex items-center gap-2 rounded-full border border-white/40 px-4 py-1.5 text-sm font-semibold text-white hover:bg-white/10 transition"
+ @click="relaunch"
+ >
+ <MaterialDesignIcon icon-name="restart" class="w-4 h-4" />
+ {{ $t("interfaces.restart_now") }}
+ </button>
</div>
- <button
- v-if="isElectron"
- type="button"
- class="ml-auto inline-flex items-center gap-2 rounded-full border border-white/40 px-4 py-1.5 text-sm font-semibold text-white hover:bg-white/10 transition"
- @click="relaunch"
- >
- <MaterialDesignIcon icon-name="restart" class="w-4 h-4" />
- {{ $t("interfaces.restart_now") }}
- </button>
- </div>
- <div class="glass-card space-y-4">
- <div class="flex flex-wrap gap-3 items-center">
- <div class="flex-1">
- <div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
- {{ $t("interfaces.manage") }}
+ <div class="glass-card space-y-4">
+ <div class="flex flex-wrap gap-3 items-center">
+ <div class="flex-1">
+ <div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
+ {{ $t("interfaces.manage") }}
+ </div>
+ <div class="text-xl font-semibold text-gray-900 dark:text-white">
+ {{ $t("interfaces.title") }}
+ </div>
+ <div class="text-sm text-gray-600 dark:text-gray-300">
+ {{ $t("interfaces.description") }}
+ </div>
</div>
- <div class="text-xl font-semibold text-gray-900 dark:text-white">
- {{ $t("interfaces.title") }}
- </div>
- <div class="text-sm text-gray-600 dark:text-gray-300">{{ $t("interfaces.description") }}</div>
- </div>
- <div class="flex flex-wrap gap-2">
- <RouterLink :to="{ name: 'interfaces.add' }" class="primary-chip px-4 py-2 text-sm">
- <MaterialDesignIcon icon-name="plus" class="w-4 h-4" />
- {{ $t("interfaces.add_interface") }}
- </RouterLink>
- <button type="button" class="secondary-chip text-sm" @click="showImportInterfacesModal">
- <MaterialDesignIcon icon-name="import" class="w-4 h-4" />
- {{ $t("interfaces.import") }}
- </button>
- <button type="button" class="secondary-chip text-sm" @click="exportInterfaces">
- <MaterialDesignIcon icon-name="export" class="w-4 h-4" />
- {{ $t("interfaces.export_all") }}
- </button>
- <!--
+ <div class="flex flex-wrap gap-2">
+ <RouterLink :to="{ name: 'interfaces.add' }" class="primary-chip px-4 py-2 text-sm">
+ <MaterialDesignIcon icon-name="plus" class="w-4 h-4" />
+ {{ $t("interfaces.add_interface") }}
+ </RouterLink>
+ <button type="button" class="secondary-chip text-sm" @click="showImportInterfacesModal">
+ <MaterialDesignIcon icon-name="import" class="w-4 h-4" />
+ {{ $t("interfaces.import") }}
+ </button>
+ <button type="button" class="secondary-chip text-sm" @click="exportInterfaces">
+ <MaterialDesignIcon icon-name="export" class="w-4 h-4" />
+ {{ $t("interfaces.export_all") }}
+ </button>
+ <!--
<button
type="button"
class="secondary-chip text-sm bg-amber-500/10 hover:bg-amber-500/20 text-amber-600 dark:text-amber-400 border-amber-500/30"
@@ -63,71 +66,73 @@
/>
{{ reloadingRns ? $t("app.reloading_rns") : $t("app.reload_rns") }}
</button>
- -->
- </div>
- </div>
- <div class="flex flex-wrap gap-3 items-center">
- <div class="flex-1">
- <input
- v-model="searchTerm"
- type="text"
- :placeholder="$t('interfaces.search_placeholder')"
- class="input-field"
- />
- </div>
- <div class="flex gap-2 flex-wrap">
- <button
- type="button"
- :class="filterChipClass(statusFilter === 'all')"
- @click="setStatusFilter('all')"
- >
- {{ $t("interfaces.all") }}
- </button>
- <button
- type="button"
- :class="filterChipClass(statusFilter === 'enabled')"
- @click="setStatusFilter('enabled')"
- >
- {{ $t("app.enabled") }}
- </button>
- <button
- type="button"
- :class="filterChipClass(statusFilter === 'disabled')"
- @click="setStatusFilter('disabled')"
- >
- {{ $t("app.disabled") }}
- </button>
+ --></div>
</div>
- <div class="w-full sm:w-60">
- <select v-model="typeFilter" class="input-field">
- <option value="all">{{ $t("interfaces.all_types") }}</option>
- <option v-for="type in sortedInterfaceTypes" :key="type" :value="type">{{ type }}</option>
- </select>
+ <div class="flex flex-wrap gap-3 items-center">
+ <div class="flex-1">
+ <input
+ v-model="searchTerm"
+ type="text"
+ :placeholder="$t('interfaces.search_placeholder')"
+ class="input-field"
+ />
+ </div>
+ <div class="flex gap-2 flex-wrap">
+ <button
+ type="button"
+ :class="filterChipClass(statusFilter === 'all')"
+ @click="setStatusFilter('all')"
+ >
+ {{ $t("interfaces.all") }}
+ </button>
+ <button
+ type="button"
+ :class="filterChipClass(statusFilter === 'enabled')"
+ @click="setStatusFilter('enabled')"
+ >
+ {{ $t("app.enabled") }}
+ </button>
+ <button
+ type="button"
+ :class="filterChipClass(statusFilter === 'disabled')"
+ @click="setStatusFilter('disabled')"
+ >
+ {{ $t("app.disabled") }}
+ </button>
+ </div>
+ <div class="w-full sm:w-60">
+ <select v-model="typeFilter" class="input-field">
+ <option value="all">{{ $t("interfaces.all_types") }}</option>
+ <option v-for="type in sortedInterfaceTypes" :key="type" :value="type">
+ {{ type }}
+ </option>
+ </select>
+ </div>
</div>
</div>
- </div>
- <div
- v-if="filteredInterfaces.length === 0"
- class="glass-card text-center py-10 text-gray-500 dark:text-gray-300"
- >
- <MaterialDesignIcon icon-name="lan-disconnect" class="w-10 h-10 mx-auto mb-3" />
- <div class="text-lg font-semibold">{{ $t("interfaces.no_interfaces_found") }}</div>
- <div class="text-sm">{{ $t("interfaces.no_interfaces_description") }}</div>
- </div>
+ <div
+ v-if="filteredInterfaces.length === 0"
+ class="glass-card text-center py-10 text-gray-500 dark:text-gray-300"
+ >
+ <MaterialDesignIcon icon-name="lan-disconnect" class="w-10 h-10 mx-auto mb-3" />
+ <div class="text-lg font-semibold">{{ $t("interfaces.no_interfaces_found") }}</div>
+ <div class="text-sm">{{ $t("interfaces.no_interfaces_description") }}</div>
+ </div>
- <div v-else class="grid gap-4 xl:grid-cols-2">
- <Interface
- v-for="iface of filteredInterfaces"
- :key="iface._name"
- :iface="iface"
- :is-reticulum-running="isReticulumRunning"
- @enable="enableInterface(iface._name)"
- @disable="disableInterface(iface._name)"
- @edit="editInterface(iface._name)"
- @export="exportInterface(iface._name)"
- @delete="deleteInterface(iface._name)"
- />
+ <div v-else class="grid gap-4 xl:grid-cols-2">
+ <Interface
+ v-for="iface of filteredInterfaces"
+ :key="iface._name"
+ :iface="iface"
+ :is-reticulum-running="isReticulumRunning"
+ @enable="enableInterface(iface._name)"
+ @disable="disableInterface(iface._name)"
+ @edit="editInterface(iface._name)"
+ @export="exportInterface(iface._name)"
+ @delete="deleteInterface(iface._name)"
+ />
+ </div>
</div>
</div>
</div>
diff --git a/meshchatx/src/frontend/components/map/MapPage.vue b/meshchatx/src/frontend/components/map/MapPage.vue
index ae39c2f7..0062bc9c 100644
--- a/meshchatx/src/frontend/components/map/MapPage.vue
+++ b/meshchatx/src/frontend/components/map/MapPage.vue
@@ -76,14 +76,16 @@
<!-- map container -->
<div class="relative flex-1 min-h-0">
<!-- drawing toolbar -->
- <div class="absolute top-14 left-1/2 -translate-x-1/2 sm:top-2 z-20 flex flex-col gap-2 transform-gpu">
+ <div
+ class="absolute top-14 left-1/2 -translate-x-1/2 sm:top-2 z-20 flex flex-col gap-2 transform-gpu w-max max-w-[98vw]"
+ >
<div
- class="bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl overflow-hidden flex flex-row p-1 gap-1 border-0"
+ class="bg-white dark:bg-zinc-900 rounded-2xl shadow-2xl overflow-hidden flex flex-row p-0.5 sm:p-1 gap-0 sm:gap-0.5 border-0"
>
<button
v-for="tool in drawingTools"
:key="tool.type"
- class="p-2 rounded-xl transition-all hover:scale-110 active:scale-90"
+ class="p-1.5 sm:p-2 rounded-xl transition-all hover:scale-110 active:scale-90"
:class="[
(drawType === tool.type && !isMeasuring) || (tool.type === 'Export' && isExportMode)
? 'bg-blue-500 text-white shadow-lg shadow-blue-500/30'
@@ -92,11 +94,11 @@
:title="tool.type === 'Export' ? 'MBTiles exporter' : $t(`map.tool_${tool.type.toLowerCase()}`)"
@click="tool.type === 'Export' ? toggleExportMode() : toggleDraw(tool.type)"
>
- <v-icon :icon="'mdi-' + tool.icon" size="20"></v-icon>
+ <v-icon :icon="'mdi-' + tool.icon" size="18" class="sm:!size-5"></v-icon>
</button>
- <div class="w-px h-6 bg-gray-200 dark:bg-zinc-800 my-auto mx-1"></div>
+ <div class="w-px h-6 bg-gray-200 dark:bg-zinc-800 my-auto mx-0.5 sm:mx-1"></div>
<button
- class="p-2 rounded-xl transition-all hover:scale-110 active:scale-90"
+ class="p-1.5 sm:p-2 rounded-xl transition-all hover:scale-110 active:scale-90"
:class="[
isMeasuring
? 'bg-indigo-500 text-white shadow-lg shadow-indigo-500/30'
@@ -105,37 +107,37 @@
:title="$t('map.tool_measure')"
@click="toggleMeasure"
>
- <v-icon icon="mdi-ruler" size="20"></v-icon>
+ <v-icon icon="mdi-ruler" size="18" class="sm:!size-5"></v-icon>
</button>
<button
- class="p-2 rounded-xl hover:bg-red-50 dark:hover:bg-red-900/20 text-red-500 transition-all hover:scale-110 active:scale-90"
+ class="p-1.5 sm:p-2 rounded-xl hover:bg-red-50 dark:hover:bg-red-900/20 text-red-500 transition-all hover:scale-110 active:scale-90"
:title="$t('map.tool_clear')"
@click="clearDrawings"
>
- <v-icon icon="mdi-trash-can-outline" size="20"></v-icon>
+ <v-icon icon="mdi-trash-can-outline" size="18" class="sm:!size-5"></v-icon>
</button>
- <div class="w-px h-6 bg-gray-200 dark:bg-zinc-800 my-auto mx-1"></div>
+ <div class="w-px h-6 bg-gray-200 dark:bg-zinc-800 my-auto mx-0.5 sm:mx-1"></div>
<button
- class="p-2 rounded-xl hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-600 dark:text-gray-400 transition-all hover:scale-110 active:scale-90"
+ class="p-1.5 sm:p-2 rounded-xl hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-600 dark:text-gray-400 transition-all hover:scale-110 active:scale-90"
:title="$t('map.save_drawing')"
@click="showSaveDrawingModal = true"
>
- <v-icon icon="mdi-content-save-outline" size="20"></v-icon>
+ <v-icon icon="mdi-content-save-outline" size="18" class="sm:!size-5"></v-icon>
</button>
<button
- class="p-2 rounded-xl hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-600 dark:text-gray-400 transition-all hover:scale-110 active:scale-90"
+ class="p-1.5 sm:p-2 rounded-xl hover:bg-gray-100 dark:hover:bg-zinc-800 text-gray-600 dark:text-gray-400 transition-all hover:scale-110 active:scale-90"
:title="$t('map.load_drawing')"
@click="openLoadDrawingModal"
>
- <v-icon icon="mdi-folder-open-outline" size="20"></v-icon>
+ <v-icon icon="mdi-folder-open-outline" size="18" class="sm:!size-5"></v-icon>
</button>
- <div class="w-px h-6 bg-gray-200 dark:bg-zinc-800 my-auto mx-1"></div>
+ <div class="w-px h-6 bg-gray-200 dark:bg-zinc-800 my-auto mx-0.5 sm:mx-1"></div>
<button
- class="p-2 rounded-xl hover:bg-blue-50 dark:hover:bg-blue-900/20 text-blue-500 transition-all hover:scale-110 active:scale-90"
+ class="p-1.5 sm:p-2 rounded-xl hover:bg-blue-50 dark:hover:bg-blue-900/20 text-blue-500 transition-all hover:scale-110 active:scale-90"
:title="$t('map.go_to_my_location')"
@click="goToMyLocation"
>
- <v-icon icon="mdi-crosshairs-gps" size="20"></v-icon>
+ <v-icon icon="mdi-crosshairs-gps" size="18" class="sm:!size-5"></v-icon>
</button>
</div>
</div>
diff --git a/meshchatx/src/frontend/components/messages/ConversationViewer.vue b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
index b72ed042..7b8f3f5c 100644
--- a/meshchatx/src/frontend/components/messages/ConversationViewer.vue
+++ b/meshchatx/src/frontend/components/messages/ConversationViewer.vue
@@ -954,7 +954,7 @@
</div>
<!-- compose message input -->
- <div class="w-full">
+ <div class="w-full relative">
<input
id="compose-input"
ref="compose-input"
@@ -964,7 +964,53 @@
class="w-full bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 text-gray-900 dark:text-zinc-100 text-sm rounded-xl focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 px-4 py-2.5 shadow-sm transition-all placeholder:text-gray-400 dark:placeholder:text-zinc-500"
placeholder="Enter LXMF address..."
@keydown.enter.exact.prevent="onComposeEnterPressed"
+ @keydown.up.prevent="handleComposeInputUp"
+ @keydown.down.prevent="handleComposeInputDown"
+ @focus="isComposeInputFocused = true"
+ @blur="onComposeInputBlur"
/>
+ <!-- Suggestions Dropdown -->
+ <div
+ v-if="isComposeInputFocused && composeSuggestions.length > 0"
+ class="absolute z-50 left-0 right-0 bottom-full mb-1 bg-white dark:bg-zinc-900 border border-gray-200 dark:border-zinc-800 rounded-xl shadow-xl overflow-hidden animate-in fade-in slide-in-from-bottom-2 duration-200"
+ >
+ <div
+ v-for="(suggestion, index) in composeSuggestions"
+ :key="suggestion.hash"
+ class="px-4 py-2.5 flex items-center gap-3 cursor-pointer transition-colors"
+ :class="[
+ index === selectedComposeSuggestionIndex
+ ? 'bg-blue-50 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400'
+ : 'hover:bg-gray-50 dark:hover:bg-zinc-800/50 text-gray-700 dark:text-zinc-300',
+ ]"
+ @mousedown.prevent="selectComposeSuggestion(suggestion)"
+ >
+ <div
+ class="shrink-0 size-8 rounded-full flex items-center justify-center text-xs"
+ :class="
+ suggestion.type === 'contact'
+ ? 'bg-blue-100 dark:bg-blue-900/40 text-blue-600'
+ : 'bg-gray-100 dark:bg-zinc-800 text-gray-500'
+ "
+ >
+ <MaterialDesignIcon :icon-name="suggestion.icon" class="size-4" />
+ </div>
+ <div class="flex-1 min-w-0">
+ <div class="text-sm font-bold truncate">
+ {{ suggestion.name }}
+ </div>
+ <div class="text-[10px] font-mono opacity-50 truncate">
+ {{ formatDestinationHash(suggestion.hash) }}
+ </div>
+ </div>
+ <div
+ v-if="suggestion.type === 'contact'"
+ class="text-[10px] uppercase font-bold tracking-widest opacity-30"
+ >
+ Contact
+ </div>
+ </div>
+ </div>
</div>
</div>
</div>
@@ -1092,6 +1138,8 @@ export default {
isSendingMessage: false,
autoScrollOnNewMessage: true,
composeAddress: "",
+ isComposeInputFocused: false,
+ selectedComposeSuggestionIndex: -1,
isShareContactModalOpen: false,
contacts: [],
@@ -1146,6 +1194,48 @@ export default {
latestConversations() {
return this.conversations.slice(0, 4);
},
+ composeSuggestions() {
+ if (!this.isComposeInputFocused) return [];
+
+ const search = this.composeAddress.toLowerCase().trim();
+ const suggestions = [];
+ const seenHashes = new Set();
+
+ // 1. Check contacts
+ this.contacts.forEach((c) => {
+ const hash = c.remote_identity_hash;
+ if (!seenHashes.has(hash)) {
+ if (!search || c.name.toLowerCase().includes(search) || hash.toLowerCase().includes(search)) {
+ suggestions.push({
+ name: c.name,
+ hash: hash,
+ type: "contact",
+ icon: "account",
+ });
+ seenHashes.add(hash);
+ }
+ }
+ });
+
+ // 2. Check recent conversations
+ this.conversations.forEach((c) => {
+ const hash = c.destination_hash;
+ if (!seenHashes.has(hash)) {
+ const name = c.custom_display_name ?? c.display_name;
+ if (!search || name.toLowerCase().includes(search) || hash.toLowerCase().includes(search)) {
+ suggestions.push({
+ name: name,
+ hash: hash,
+ type: "recent",
+ icon: "history",
+ });
+ seenHashes.add(hash);
+ }
+ }
+ });
+
+ return suggestions.slice(0, 10);
+ },
canSendMessage() {
// can send if message text is present
const messageText = this.newMessageText.trim();
@@ -1249,8 +1339,19 @@ export default {
// check translator
this.checkTranslator();
+
+ // fetch contacts for suggestions
+ this.fetchContacts();
},
methods: {
+ async fetchContacts() {
+ try {
+ const response = await window.axios.get("/api/v1/telephone/contacts");
+ this.contacts = response.data;
+ } catch (e) {
+ console.log("Failed to fetch contacts:", e);
+ }
+ },
async checkTranslator() {
if (!this.config?.translator_enabled) {
this.hasTranslator = false;
@@ -1575,8 +1676,47 @@ export default {
await this.handleComposeAddress(destinationHash);
},
onComposeEnterPressed() {
+ if (
+ this.selectedComposeSuggestionIndex >= 0 &&
+ this.selectedComposeSuggestionIndex < this.composeSuggestions.length
+ ) {
+ const suggestion = this.composeSuggestions[this.selectedComposeSuggestionIndex];
+ this.selectComposeSuggestion(suggestion);
+ } else {
+ this.onComposeSubmit();
+ }
+ },
+ handleComposeInputUp() {
+ if (this.composeSuggestions.length > 0) {
+ if (this.selectedComposeSuggestionIndex > 0) {
+ this.selectedComposeSuggestionIndex--;
+ } else {
+ this.selectedComposeSuggestionIndex = this.composeSuggestions.length - 1;
+ }
+ }
+ },
+ handleComposeInputDown() {
+ if (this.composeSuggestions.length > 0) {
+ if (this.selectedComposeSuggestionIndex < this.composeSuggestions.length - 1) {
+ this.selectedComposeSuggestionIndex++;
+ } else {
+ this.selectedComposeSuggestionIndex = 0;
+ }
+ }
+ },
+ selectComposeSuggestion(suggestion) {
+ this.composeAddress = suggestion.hash;
+ this.isComposeInputFocused = false;
+ this.selectedComposeSuggestionIndex = -1;
this.onComposeSubmit();
},
+ onComposeInputBlur() {
+ // Delay blur to allow mousedown on suggestions
+ setTimeout(() => {
+ this.isComposeInputFocused = false;
+ this.selectedComposeSuggestionIndex = -1;
+ }, 200);
+ },
async handleComposeAddress(destinationHash) {
if (destinationHash.startsWith("lxmf@")) {
destinationHash = destinationHash.replace("lxmf@", "");
diff --git a/meshchatx/src/frontend/components/micron-editor/MicronEditorPage.vue b/meshchatx/src/frontend/components/micron-editor/MicronEditorPage.vue
index 4f6f44d1..3d6464e3 100644
--- a/meshchatx/src/frontend/components/micron-editor/MicronEditorPage.vue
+++ b/meshchatx/src/frontend/components/micron-editor/MicronEditorPage.vue
@@ -8,11 +8,21 @@
<div class="bg-teal-100 dark:bg-teal-900/30 p-1.5 rounded-xl shrink-0">
<MaterialDesignIcon icon-name="code-tags" class="size-5 text-teal-600 dark:text-teal-400" />
</div>
- <h1
- class="text-sm font-bold text-gray-900 dark:text-white uppercase tracking-wider hidden sm:block truncate"
- >
- {{ $t("tools.micron_editor.title") }}
- </h1>
+ <div class="flex flex-col">
+ <h1
+ class="text-sm font-bold text-gray-900 dark:text-white uppercase tracking-wider hidden sm:block truncate leading-tight"
+ >
+ {{ $t("tools.micron_editor.title") }}
+ </h1>
+ <a
+ href="https://github.com/RFnexus"
+ target="_blank"
+ rel="noopener noreferrer"
+ class="text-[10px] text-gray-500 hover:text-teal-500 transition-colors hidden sm:block leading-tight"
+ >
+ {{ $t("tools.micron_editor.parser_by") }} RFnexus
+ </a>
+ </div>
</div>
<div class="flex items-center gap-2">
<button
@@ -198,17 +208,18 @@ export default {
name: this.$t("tools.micron_editor.main_tab"),
content: oldContent,
},
+ this.createGuideTab(Date.now() + 1),
];
localStorage.removeItem(this.storageKey);
await micronStorage.saveTabs(this.tabs);
} else {
- this.tabs = [this.createDefaultTab()];
+ this.tabs = [this.createDefaultTab(), this.createGuideTab(Date.now() + 1)];
await micronStorage.saveTabs(this.tabs);
}
}
} catch (error) {
console.warn("Failed to load content from IndexedDB:", error);
- this.tabs = [this.createDefaultTab()];
+ this.tabs = [this.createDefaultTab(), this.createGuideTab(Date.now() + 1)];
}
this.activeTabIndex = 0;
},
@@ -219,6 +230,13 @@ export default {
content: this.getDefaultContent(),
};
},
+ createGuideTab(id = Date.now()) {
+ return {
+ id: id,
+ name: this.$t("tools.micron_editor.guide_tab"),
+ content: this.getGuideContent(),
+ };
+ },
addTab() {
const newTab = {
id: Date.now(),
@@ -259,7 +277,7 @@ export default {
async resetAll() {
if (confirm(this.$t("tools.micron_editor.confirm_reset"))) {
await micronStorage.clearAll();
- this.tabs = [this.createDefaultTab()];
+ this.tabs = [this.createDefaultTab(), this.createGuideTab(Date.now() + 1)];
this.activeTabIndex = 0;
this.renderActiveTab();
await this.saveContent();
@@ -319,6 +337,393 @@ Use \\${b}= to start/end literal blocks that won't be interpreted.
${b}=
This is a literal block
+${b}=
+`;
+ },
+ getGuideContent() {
+ const b = "`";
+ return `-∿
+<
+
+${b}c${b}!Hello!${b}! This is output from ${b}*micron${b}*
+Micron generates formatted text for your terminal
+${b}a
+
+
+-∿
+<
+
+
+Nomad Network supports a simple and functional markup language called ${b}*micron${b}*. If you are familiar with ${b}*markdown${b}* or ${b}*HTML${b}*, you will feel right at home writing pages with micron.
+
+With micron you can easily create structured documents and pages with formatting, colors, glyphs and icons, ideal for display in terminals.
+
+>>Recommendations and Requirements
+
+While micron can output formatted text to even the most basic terminal, there's a few capabilities your terminal ${b}*must${b}* support to display micron output correctly, and some that, while not strictly necessary, make the experience a lot better.
+
+Formatting such as ${b}_underline${b}_, ${b}!bold${b}! or ${b}*italics${b}* will be displayed if your terminal supports it.
+
+If you are having trouble getting micron output to display correctly, try using ${b}*gnome-terminal${b}* or ${b}*alacritty${b}*, which should work with all formatting options out of the box. Most other terminals will work fine as well, but you might have to change some settings to get certain formatting to display correctly.
+
+>>>Encoding
+
+All micron sources are intepreted as UTF-8, and micron assumes it can output UTF-8 characters to the terminal. If your terminal does not support UTF-8, output will be faulty.
+
+>>>Colors
+
+Shading and coloring text and backgrounds is integral to micron output, and while micron will attempt to gracefully degrade output even to 1-bit terminals, you will get the best output with terminals supporting at least 256 colors. True-color support is recommended.
+
+>>>Terminal Font
+
+While any unicode capable font can be used with micron, it's highly recommended to use a ${b}*"Nerd Font"${b}* (see https://www.nerdfonts.com/), which will add a lot of extra glyphs and icons to your output.
+
+> A Few Demo Outputs
+
+${b}F222${b}Bddd
+
+${b}cWith micron, you can control layout and presentation
+${b}a
+
+${b}${b}
+
+${b}B33f
+
+You can change background ...
+
+${b}${b}
+
+${b}B393
+
+${b}r${b}F320... and foreground colors${b}f
+${b}a
+
+${b}b
+
+If you want to make a break, horizontal dividers can be inserted. They can be plain, like the one below this text, or you can style them with unicode characters and glyphs, like the wavy divider in the beginning of this document.
+
+-
+
+${b}cText can be ${b}_underlined${b}_, ${b}!bold${b}! or ${b}*italic${b}*.
+
+You can also ${b}_${b}*${b}!B5d5${b}F222combine${b}f${b}b${b}_ ${b}_${b}Ff00f${b}Ff80o${b}Ffd0r${b}F9f0m${b}F0f2a${b}F0fdt${b}F07ft${b}F43fi${b}F70fn${b}Fe0fg${b}${b} for some fabulous effects.
+${b}a
+
+
+>>>Sections and Headings
+
+You can define an arbitrary number of sections and sub sections, each with their own named headings. Text inside sections will be automatically indented.
+
+-
+
+If you place a divider inside a section, it will adhere to the section indents.
+
+>>>>>
+If no heading text is defined, the section will appear as a sub-section without a header. This can be useful for creating indented blocks of text, like this one.
+
+>Micron tags
+
+Tags are used to format text with micron. Some tags can appear anywhere in text, and some must appear at the beginning of a line. If you need to write text that contains a sequence that would be interpreted as a tag, you can escape it with the character \\.
+
+In the following sections, the different tags will be introduced. Any styling set within micron can be reset to the default style by using the special \\${b}\\${b} tag anywhere in the markup, which will immediately remove any formatting previously specified.
+
+>>Alignment
+
+To control text alignment use the tag \\${b}c to center text, \\${b}l to left-align, \\${b}r to right-align, and \\${b}a to return to the default alignment of the document. Alignment tags must appear at the beginning of a line. Here is an example:
+
+${b}Faaa
+${b}=
+${b}cThis line will be centered.
+So will this.
+${b}aThe alignment has now been returned to default.
+${b}rThis will be aligned to the right
+${b}${b}
+${b}=
+${b}${b}
+
+The above markup produces the following output:
+
+${b}Faaa${b}B333
+
+${b}cThis line will be centered.
+So will this.
+
+${b}aThe alignment has now been returned to default.
+
+${b}rThis will be aligned to the right
+
+${b}${b}
+
+
+>>Formatting
+
+Text can be formatted as ${b}!bold${b}! by using the \\${b}! tag, ${b}_underline${b}_ by using the \\${b}_ tag and ${b}*italic${b}* by using the \\${b}* tag.
+
+Here's an example of formatting text:
+
+${b}Faaa
+${b}=
+We shall soon see ${b}!bold${b}! paragraphs of text decorated with ${b}_underlines${b}_ and ${b}*italics${b}*. Some even dare ${b}!${b}*${b}_combine${b}${b} them!
+${b}=
+${b}${b}
+
+The above markup produces the following output:
+
+${b}Faaa${b}B333
+
+We shall soon see ${b}!bold${b}! paragraphs of text decorated with ${b}_underlines${b}_ and ${b}*italics${b}*. Some even dare ${b}!${b}*${b}_combine${b}!${b}*${b}_ them!
+
+${b}${b}
+
+
+>>Sections
+
+To create sections and subsections, use the > tag. This tag must be placed at the beginning of a line. To specify a sub-section of any level, use any number of > tags. If text is placed after a > tag, it will be used as a heading.
+
+Here is an example of sections:
+
+${b}Faaa
+${b}=
+>High Level Stuff
+This is a section. It contains this text.
+
+>>Another Level
+This is a sub section.
+
+>>>Going deeper
+A sub sub section. We could continue, but you get the point.
+
+>>>>
+Wait! It's worth noting that we can also create sections without headings. They look like this.
+${b}=
+${b}${b}
+
+The above markup produces the following output:
+
+${b}Faaa${b}B333
+>High Level Stuff
+This is a section. It contains this text.
+
+>>Another Level
+This is a sub section.
+
+>>>Going deeper
+A sub sub section. We could continue, but you get the point.
+
+>>>>
+Wait! It's worth noting that we can also create sections without headings. They look like this.
+${b}${b}
+
+
+>Colors
+
+Foreground colors can be specified with the \\${b}F tag, followed by three hexadecimal characters. To return to the default foreground color, use the \\${b}f tag. Background color is specified in the same way, but by using the \\${b}B and \\${b}b tags.
+
+Here's a few examples:
+
+${b}Faaa
+${b}=
+You can use ${b}B5d5${b}F222 color ${b}f${b}b ${b}Ff00f${b}Ff80o${b}Ffd0r${b}F9f0m${b}F0f2a${b}F0fdt${b}F07ft${b}F43fi${b}F70fn${b}Fe0fg${b}f for some fabulous effects.
+${b}=
+${b}${b}
+
+The above markup produces the following output:
+
+${b}Faaa${b}B333
+
+You can use ${b}B5d5${b}F222 color ${b}f${b}B333 ${b}Ff00f${b}Ff80o${b}Ffd0r${b}F9f0m${b}F0f2a${b}F0fdt${b}F07ft${b}F43fi${b}F70fn${b}Fe0fg${b}f for some fabulous effects.
+
+${b}${b}
+
+
+>Links
+
+Links to pages, files or other resources can be created with the \\${b}[ tag, which should always be terminated with a closing ]. You can create links with and without labels, it is up to you to control the formatting of links with other tags. Although not strictly necessary, it is good practice to at least format links with underlining.
+
+Here's a few examples:
+
+${b}Faaa
+${b}=
+Here is a link without any label: ${b}[72914442a3689add83a09a767963f57c:/page/index.mu]
+
+This is a ${b}[labeled link${b}72914442a3689add83a09a767963f57c:/page/index.mu] to the same page, but it's hard to see if you don't know it
+
+Here is ${b}F00a${b}_${b}[a more visible link${b}72914442a3689add83a09a767963f57c:/page/index.mu]${b}_${b}f
+${b}=
+${b}${b}
+
+The above markup produces the following output:
+
+${b}Faaa${b}B333
+
+Here is a link without any label: ${b}[72914442a3689add83a09a767963f57c:/page/index.mu]
+
+This is a ${b}[labeled link${b}72914442a3689add83a09a767963f57c:/page/index.mu] to the same page, but it's hard to see if you don't know it
+
+Here is ${b}F00f${b}_${b}[a more visible link${b}72914442a3689add83a09a767963f57c:/page/index.mu]${b}_${b}f
+
+${b}${b}
+
+When links like these are displayed in the built-in browser, clicking on them or activating them using the keyboard will cause the browser to load the specified URL.
+
+>Fields & Requests
+
+Nomad Network let's you use simple input fields for submitting data to node-side applications. Submitted data, along with other session variables will be available to the node-side script / program as environment variables.
+
+>>Request Links
+
+Links can contain request variables and a list of fields to submit to the node-side application. You can include all fields on the page, only specific ones, and any number of request variables. To simply submit all fields on a page to a specified node-side page, create a link like this:
+
+${b}Faaa
+${b}=
+${b}[Submit Fields${b}:/page/fields.mu${b}*]
+${b}=
+${b}${b}
+
+Note the ${b}!*${b}! following the extra ${b}!\\${b}${b}! at the end of the path. This ${b}!*${b}! denotes ${b}*all fields${b}*. You can also specify a list of fields to include:
+
+${b}Faaa
+${b}=
+${b}[Submit Fields${b}:/page/fields.mu${b}username|auth_token]
+${b}=
+${b}${b}
+
+If you want to include pre-set variables, you can do it like this:
+
+${b}Faaa
+${b}=
+${b}[Query the System${b}:/page/fields.mu${b}username|auth_token|action=view|amount=64]
+${b}=
+${b}${b}
+
+>> Fields
+
+Here's an example of creating a field. We'll create a field named ${b}!user_input${b}! and fill it with the text ${b}!Pre-defined data${b}!. Note that we are using background color tags to make the field more visible to the user:
+
+${b}Faaa
+${b}=
+A simple input field: ${b}B444${b}<user_input${b}Pre-defined data>${b}b
+${b}=
+${b}${b}
+
+You must always set a field ${b}*name${b}*, but you can of course omit the pre-defined value of the field:
+
+${b}Faaa
+${b}=
+An empty input field: ${b}B444${b}<demo_empty${b}>${b}b
+${b}=
+${b}${b}
+
+You can set the size of the field like this:
+
+${b}Faaa
+${b}=
+A sized input field: ${b}B444${b}<16|with_size${b}>${b}b
+${b}=
+${b}${b}
+
+It is possible to mask fields, for example for use with passwords and similar:
+
+${b}Faaa
+${b}=
+A masked input field: ${b}B444${b}<!|masked_demo${b}hidden text>${b}b
+${b}=
+${b}${b}
+
+And you can of course control all parameters at the same time:
+
+${b}Faaa
+${b}=
+Full control: ${b}B444${b}<!32|all_options${b}hidden text>${b}b
+${b}=
+${b}${b}
+
+Collecting the above markup produces the following output:
+
+${b}Faaa${b}B333
+
+A simple input field: ${b}B444${b}<user_input${b}Pre-defined data>${b}B333
+
+An empty input field: ${b}B444${b}<demo_empty${b}>${b}B333
+
+A sized input field: ${b}B444${b}<16|with_size${b}>${b}B333
+
+A masked input field: ${b}B444${b}<!|masked_demo${b}hidden text>${b}B333
+
+Full control: ${b}B444${b}<!32|all_options${b}hidden text>${b}B333
+${b}b
+>>> Checkboxes
+
+In addition to text fields, Checkboxes are another way of submitting data. They allow the user to make a single selection or select multiple options.
+
+${b}Faaa
+${b}=
+${b}<?|field_name|value${b}>${b}b Label Text${b}
+${b}=
+When the checkbox is checked, it's field will be set to the provided value. If there are multiple checkboxes that share the same field name, the checked values will be concatenated when they are sent to the node by a comma.
+${b}${b}
+
+${b}B444${b}<?|sign_up|1${b}>${b}b Sign me up${b}
+
+You can also pre-check both checkboxes and radio groups by appending a |* after the field value.
+
+${b}B444${b}<?|checkbox|1|*${b}>${b}b Pre-checked checkbox${b}
+
+>>> Radio groups
+
+Radio groups are another input that lets the user chose from a set of options. Unlike checkboxes, radio buttons with the same field name are mutually exclusive.
+
+Example:
+
+${b}=
+${b}B900${b}<^|color|Red${b}>${b}b Red
+
+${b}B090${b}<^|color|Green${b}>${b}b Green
+
+${b}B009${b}<^|color|Blue${b}>${b}b Blue
+${b}=
+
+will render:
+
+${b}B900${b}<^|color|Red${b}>${b}b Red
+
+${b}B090${b}<^|color|Green${b}>${b}b Green
+
+${b}B009${b}<^|color|Blue${b}>${b}b Blue
+
+In this example, when the data is submitted, ${b}B444${b} field_color${b}b will be set to whichever value from the list was selected.
+
+${b}${b}
+
+>Comments
+
+You can insert comments that will not be displayed in the output by starting a line with the # character.
+
+Here's an example:
+
+${b}Faaa
+${b}=
+# This line will not be displayed
+This line will
+${b}=
+${b}${b}
+
+The above markup produces the following output:
+
+${b}Faaa${b}B333
+
+# This line will not be displayed
+This line will
+
+${b}${b}
+
+
+>Literals
+
+To display literal content, for example source-code, or blocks of text that should not be interpreted by micron, you can use literal blocks, specified by the \\${b}= tag. Below is the source code of this entire document, presented as a literal block.
+
+-
+
${b}=
`;
},
diff --git a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue
index 9a65372a..82ca359d 100644
--- a/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue
+++ b/meshchatx/src/frontend/components/nomadnetwork/NomadNetworkPage.vue
@@ -561,21 +561,19 @@ export default {
// handle success for archived versions first (before path check)
if (nomadnetPageDownload.status === "success" && nomadnetPageDownload.is_archived_version) {
this.nodePagePath = responsePagePath;
+ this.nodePagePathUrlInput = responsePagePath;
this.isShowingArchivedVersion = true;
this.archivedAt = nomadnetPageDownload.archived_at;
this.nodePageContent = nomadnetPageDownload.page_content;
this.nodePageProgress = 100;
this.isLoadingNodePage = false;
this.currentPageDownloadId = null;
- this.renderPageContent(nomadnetPageDownload.page_path, nomadnetPageDownload.page_content);
+ this.fetchArchives();
return;
}
// ignore response if it's for a different page than currently requested/viewed
- if (responsePagePath !== this.nodePagePath) {
- console.log(
- `ignoring nomadnet page download response for ${responsePagePath} as current page is ${this.nodePagePath}`
- );
+ if (this.nodePagePath && responsePagePath !== this.nodePagePath) {
return;
}
@@ -593,22 +591,18 @@ export default {
const nomadnetPageDownloadCallback =
this.nomadnetPageDownloadCallbacks[getNomadnetPageDownloadCallbackKey];
+ // if no callback found for other statuses, return
+ if (!nomadnetPageDownloadCallback) {
+ return;
+ }
+
// handle success
if (nomadnetPageDownload.status === "success") {
- if (nomadnetPageDownloadCallback && nomadnetPageDownloadCallback.onSuccessCallback) {
+ if (nomadnetPageDownloadCallback.onSuccessCallback) {
nomadnetPageDownloadCallback.onSuccessCallback(nomadnetPageDownload.page_content);
- delete this.nomadnetPageDownloadCallbacks[getNomadnetPageDownloadCallbackKey];
- this.currentPageDownloadId = null;
- return;
}
- }
-
- // if no callback found for other statuses, return
- if (!nomadnetPageDownloadCallback) {
- console.log(
- "did not find nomadnet page download callback for key: " +
- getNomadnetPageDownloadCallbackKey
- );
+ delete this.nomadnetPageDownloadCallbacks[getNomadnetPageDownloadCallbackKey];
+ this.currentPageDownloadId = null;
return;
}
@@ -703,10 +697,14 @@ export default {
break;
}
case "nomadnet.page.archives": {
+ const currentRelativePath = this.nodePagePath?.includes(":")
+ ? this.nodePagePath.split(":").slice(1).join(":")
+ : this.nodePagePath;
+
if (
this.selectedNode &&
json.destination_hash === this.selectedNode.destination_hash &&
- json.page_path === this.nodePagePath
+ (json.page_path === this.nodePagePath || json.page_path === currentRelativePath)
) {
this.pageArchives = json.archives;
this.isLoadingArchives = false;
@@ -714,10 +712,14 @@ export default {
break;
}
case "nomadnet.page.archive.added": {
+ const currentRelativePath = this.nodePagePath?.includes(":")
+ ? this.nodePagePath.split(":").slice(1).join(":")
+ : this.nodePagePath;
+
if (
this.selectedNode &&
json.destination_hash === this.selectedNode.destination_hash &&
- json.page_path === this.nodePagePath
+ (json.page_path === this.nodePagePath || json.page_path === currentRelativePath)
) {
ToastUtils.success(this.$t("nomadnet.page_archived_successfully"));
this.fetchArchives();
@@ -883,6 +885,7 @@ export default {
this.archivedAt = null;
this.nodePagePath = `${destinationHash}:${pagePath}`;
this.nodePageContent = null;
+ this.pageArchives = [];
this.nodePageProgress = 0;
// update url bar
@@ -905,8 +908,8 @@ export default {
// if page is cache, we can just return it now
if (cachedNodePageContent != null) {
this.nodePageContent = cachedNodePageContent;
- this.renderPageContent(pagePath, cachedNodePageContent);
this.isLoadingNodePage = false;
+ this.fetchArchives();
return;
}
}
@@ -918,7 +921,6 @@ export default {
(pageContent) => {
// do nothing if callback is for a previous request
if (seq !== this.nodePageRequestSequence) {
- console.log("ignoring page content callback for previous page request");
return;
}
@@ -929,17 +931,18 @@ export default {
const nodePagePathCacheKey = `${destinationHash}:${pagePath}`;
this.nodePageCache[nodePagePathCacheKey] = this.nodePageContent;
- // update page content
- this.renderPageContent(pagePath, pageContent);
+ // update status
this.isLoadingNodePage = false;
// update node path
this.getNodePath(destinationHash);
+
+ // check if this page has archives
+ this.fetchArchives();
},
(failureReason) => {
// do nothing if callback is for a previous request
if (seq !== this.nodePageRequestSequence) {
- console.log("ignoring failure callback for previous page request");
return;
}
@@ -953,7 +956,6 @@ export default {
(progress) => {
// do nothing if callback is for a previous request
if (seq !== this.nodePageRequestSequence) {
- console.log("ignoring progress callback for previous page request");
return;
}
@@ -1329,11 +1331,15 @@ export default {
fetchArchives() {
if (!this.selectedNode || !this.nodePagePath) return;
this.isLoadingArchives = true;
+
+ const parsed = this.parseNomadnetworkUrl(this.nodePagePath);
+ if (!parsed) return;
+
WebSocketConnection.send(
JSON.stringify({
type: "nomadnet.page.archives.get",
destination_hash: this.selectedNode.destination_hash,
- page_path: this.nodePagePath,
+ page_path: parsed.path,
})
);
},
@@ -1343,6 +1349,13 @@ export default {
this.isShowingArchivedVersion = false;
this.archivedAt = null;
this.nodePageProgress = 0;
+
+ const archive = this.pageArchives.find((a) => a.id === archiveId);
+ if (archive) {
+ this.nodePagePath = `${archive.destination_hash}:${archive.page_path}`;
+ this.nodePagePathUrlInput = this.nodePagePath;
+ }
+
WebSocketConnection.send(
JSON.stringify({
type: "nomadnet.page.archive.load",
@@ -1354,11 +1367,15 @@ export default {
manualArchive() {
if (!this.selectedNode || !this.nodePagePath || !this.nodePageContent) return;
ToastUtils.info(this.$t("nomadnet.archiving_page"));
+
+ const parsed = this.parseNomadnetworkUrl(this.nodePagePath);
+ if (!parsed) return;
+
WebSocketConnection.send(
JSON.stringify({
type: "nomadnet.page.archive.add",
destination_hash: this.selectedNode.destination_hash,
- page_path: this.nodePagePath,
+ page_path: parsed.path,
content: this.nodePageContent,
})
);
diff --git a/meshchatx/src/frontend/components/tools/PaperMessagePage.vue b/meshchatx/src/frontend/components/tools/PaperMessagePage.vue
index 74aaefd8..b1c24284 100644
--- a/meshchatx/src/frontend/components/tools/PaperMessagePage.vue
+++ b/meshchatx/src/frontend/components/tools/PaperMessagePage.vue
@@ -2,216 +2,218 @@
<div
class="flex flex-col flex-1 overflow-hidden min-w-0 bg-gradient-to-br from-slate-50 via-slate-100 to-white dark:from-zinc-950 dark:via-zinc-900 dark:to-zinc-900"
>
- <div class="overflow-y-auto p-3 md:p-4 max-w-5xl mx-auto w-full">
- <!-- header -->
- <div class="glass-card mb-4">
- <div class="flex items-center gap-3">
- <div class="p-2 bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 rounded-xl">
- <MaterialDesignIcon icon-name="qrcode" class="size-6" />
- </div>
- <div>
- <h2 class="text-xl font-bold text-gray-900 dark:text-white tracking-tight">
- Paper Message Generator
- </h2>
- <p class="text-xs text-gray-600 dark:text-gray-400">
- Generate signed LXMF messages for physical delivery or offline transfer.
- </p>
+ <div class="flex-1 overflow-y-auto w-full">
+ <div class="p-3 md:p-4 max-w-5xl mx-auto w-full">
+ <!-- header -->
+ <div class="glass-card mb-4">
+ <div class="flex items-center gap-3">
+ <div class="p-2 bg-blue-100 dark:bg-blue-900/30 text-blue-600 dark:text-blue-400 rounded-xl">
+ <MaterialDesignIcon icon-name="qrcode" class="size-6" />
+ </div>
+ <div>
+ <h2 class="text-xl font-bold text-gray-900 dark:text-white tracking-tight">
+ Paper Message Generator
+ </h2>
+ <p class="text-xs text-gray-600 dark:text-gray-400">
+ Generate signed LXMF messages for physical delivery or offline transfer.
+ </p>
+ </div>
</div>
</div>
- </div>
- <div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
- <!-- composer -->
- <div class="space-y-4">
- <section class="glass-card">
- <div class="glass-card__header">
- <h2 class="flex items-center gap-2">
- <MaterialDesignIcon icon-name="pencil-outline" class="size-5 text-gray-400" />
- Compose Message
- </h2>
- </div>
- <div class="glass-card__body space-y-3">
- <div>
- <label
- class="block text-[10px] font-bold text-gray-400 dark:text-zinc-500 uppercase tracking-widest mb-1.5"
- >
- Recipient Address
- </label>
- <input
- v-model="destinationHash"
- type="text"
- placeholder="Destination hash (e.g. a39610...)"
- class="input-field font-mono text-sm"
- maxlength="32"
- />
+ <div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
+ <!-- composer -->
+ <div class="space-y-4">
+ <section class="glass-card">
+ <div class="glass-card__header">
+ <h2 class="flex items-center gap-2">
+ <MaterialDesignIcon icon-name="pencil-outline" class="size-5 text-gray-400" />
+ Compose Message
+ </h2>
</div>
- <div>
- <label
- class="block text-[10px] font-bold text-gray-400 dark:text-zinc-500 uppercase tracking-widest mb-1.5"
- >
- Subject (Optional)
- </label>
- <input
- v-model="title"
- type="text"
- placeholder="Message title..."
- class="input-field text-sm"
- />
- </div>
- <div>
- <label
- class="block text-[10px] font-bold text-gray-400 dark:text-zinc-500 uppercase tracking-widest mb-1.5"
+ <div class="glass-card__body space-y-3">
+ <div>
+ <label
+ class="block text-[10px] font-bold text-gray-400 dark:text-zinc-500 uppercase tracking-widest mb-1.5"
+ >
+ Recipient Address
+ </label>
+ <input
+ v-model="destinationHash"
+ type="text"
+ placeholder="Destination hash (e.g. a39610...)"
+ class="input-field font-mono text-sm"
+ maxlength="32"
+ />
+ </div>
+ <div>
+ <label
+ class="block text-[10px] font-bold text-gray-400 dark:text-zinc-500 uppercase tracking-widest mb-1.5"
+ >
+ Subject (Optional)
+ </label>
+ <input
+ v-model="title"
+ type="text"
+ placeholder="Message title..."
+ class="input-field text-sm"
+ />
+ </div>
+ <div>
+ <label
+ class="block text-[10px] font-bold text-gray-400 dark:text-zinc-500 uppercase tracking-widest mb-1.5"
+ >
+ Message Content
+ </label>
+ <textarea
+ v-model="content"
+ rows="4"
+ placeholder="Type your message here..."
+ class="input-field resize-none text-sm"
+ ></textarea>
+ </div>
+ <button
+ type="button"
+ class="w-full flex items-center justify-center gap-2 py-2.5 px-4 bg-blue-600 hover:bg-blue-700 text-white rounded-xl font-bold shadow-lg shadow-blue-500/20 transition-all active:scale-[0.98] disabled:opacity-50 disabled:pointer-events-none text-sm"
+ :disabled="!canGenerate || isGenerating"
+ @click="generatePaperMessage"
>
- Message Content
- </label>
- <textarea
- v-model="content"
- rows="4"
- placeholder="Type your message here..."
- class="input-field resize-none text-sm"
- ></textarea>
+ <template v-if="isGenerating">
+ <div
+ class="size-4 border-2 border-white/20 border-t-white rounded-full animate-spin"
+ ></div>
+ Generating...
+ </template>
+ <template v-else>
+ <MaterialDesignIcon icon-name="qrcode-plus" class="size-5" />
+ Generate Paper Message
+ </template>
+ </button>
</div>
- <button
- type="button"
- class="w-full flex items-center justify-center gap-2 py-2.5 px-4 bg-blue-600 hover:bg-blue-700 text-white rounded-xl font-bold shadow-lg shadow-blue-500/20 transition-all active:scale-[0.98] disabled:opacity-50 disabled:pointer-events-none text-sm"
- :disabled="!canGenerate || isGenerating"
- @click="generatePaperMessage"
- >
- <template v-if="isGenerating">
- <div
- class="size-4 border-2 border-white/20 border-t-white rounded-full animate-spin"
- ></div>
- Generating...
- </template>
- <template v-else>
- <MaterialDesignIcon icon-name="qrcode-plus" class="size-5" />
- Generate Paper Message
- </template>
- </button>
- </div>
- </section>
+ </section>
- <!-- read / ingest section -->
- <section class="glass-card">
- <div class="glass-card__header">
- <h2 class="flex items-center gap-2">
- <MaterialDesignIcon icon-name="qrcode-scan" class="size-5 text-gray-400" />
- Ingest Paper Message
- </h2>
- </div>
- <div class="glass-card__body space-y-3">
- <p class="text-xs text-gray-600 dark:text-gray-400">
- Paste an LXMF URI to decode and add it to your conversations.
- </p>
- <div class="flex gap-2">
- <input
- v-model="ingestUri"
- type="text"
- placeholder="lxmf://..."
- class="input-field flex-1 font-mono text-sm"
- @keydown.enter="ingestPaperMessage"
- />
+ <!-- read / ingest section -->
+ <section class="glass-card">
+ <div class="glass-card__header">
+ <h2 class="flex items-center gap-2">
+ <MaterialDesignIcon icon-name="qrcode-scan" class="size-5 text-gray-400" />
+ Ingest Paper Message
+ </h2>
+ </div>
+ <div class="glass-card__body space-y-3">
+ <p class="text-xs text-gray-600 dark:text-gray-400">
+ Paste an LXMF URI to decode and add it to your conversations.
+ </p>
+ <div class="flex gap-2">
+ <input
+ v-model="ingestUri"
+ type="text"
+ placeholder="lxmf://..."
+ class="input-field flex-1 font-mono text-sm"
+ @keydown.enter="ingestPaperMessage"
+ />
+ <button
+ type="button"
+ class="px-3 py-2 bg-gray-100 dark:bg-zinc-800 text-gray-700 dark:text-zinc-300 rounded-xl hover:bg-gray-200 dark:hover:bg-zinc-700 transition-colors"
+ @click="pasteFromClipboard"
+ >
+ <MaterialDesignIcon icon-name="content-paste" class="size-5" />
+ </button>
+ </div>
<button
type="button"
- class="px-3 py-2 bg-gray-100 dark:bg-zinc-800 text-gray-700 dark:text-zinc-300 rounded-xl hover:bg-gray-200 dark:hover:bg-zinc-700 transition-colors"
- @click="pasteFromClipboard"
+ class="w-full py-2.5 px-4 bg-gray-100 dark:bg-zinc-800 text-gray-700 dark:text-zinc-200 rounded-xl font-bold hover:bg-gray-200 dark:hover:bg-zinc-700 transition-all active:scale-[0.98] text-sm"
+ :disabled="!ingestUri"
+ @click="ingestPaperMessage"
>
- <MaterialDesignIcon icon-name="content-paste" class="size-5" />
+ Read LXM
</button>
</div>
- <button
- type="button"
- class="w-full py-2.5 px-4 bg-gray-100 dark:bg-zinc-800 text-gray-700 dark:text-zinc-200 rounded-xl font-bold hover:bg-gray-200 dark:hover:bg-zinc-700 transition-all active:scale-[0.98] text-sm"
- :disabled="!ingestUri"
- @click="ingestPaperMessage"
- >
- Read LXM
- </button>
- </div>
- </section>
- </div>
+ </section>
+ </div>
- <!-- preview / result -->
- <div class="space-y-4">
- <section v-if="generatedUri" class="glass-card overflow-hidden">
- <div class="glass-card__header bg-blue-50/50 dark:bg-blue-900/10">
- <h2 class="text-blue-600 dark:text-blue-400">Generated QR Code</h2>
- </div>
- <div class="glass-card__body flex flex-col items-center p-4 sm:p-6">
- <div class="p-3 bg-white rounded-2xl shadow-inner border border-gray-100 mb-6">
- <div class="size-40 sm:size-48 flex items-center justify-center overflow-hidden">
- <canvas ref="qrcode"></canvas>
- </div>
+ <!-- preview / result -->
+ <div class="space-y-4">
+ <section v-if="generatedUri" class="glass-card overflow-hidden">
+ <div class="glass-card__header bg-blue-50/50 dark:bg-blue-900/10">
+ <h2 class="text-blue-600 dark:text-blue-400">Generated QR Code</h2>
</div>
+ <div class="glass-card__body flex flex-col items-center p-4 sm:p-6">
+ <div class="p-3 bg-white rounded-2xl shadow-inner border border-gray-100 mb-6">
+ <div class="size-40 sm:size-48 flex items-center justify-center overflow-hidden">
+ <canvas ref="qrcode"></canvas>
+ </div>
+ </div>
- <div class="w-full space-y-3">
- <div
- class="bg-gray-50 dark:bg-zinc-800/50 rounded-2xl p-3 border border-gray-100 dark:border-zinc-700/50"
- >
- <label
- class="block text-[9px] font-bold text-gray-400 dark:text-zinc-500 uppercase tracking-widest mb-1.5"
+ <div class="w-full space-y-3">
+ <div
+ class="bg-gray-50 dark:bg-zinc-800/50 rounded-2xl p-3 border border-gray-100 dark:border-zinc-700/50"
>
- LXMF URI
- </label>
- <div class="flex gap-2">
- <div
- class="flex-1 font-mono text-[10px] break-all text-gray-600 dark:text-zinc-300 bg-white dark:bg-zinc-900 p-2 rounded-lg border border-gray-200 dark:border-zinc-700 max-h-20 overflow-y-auto"
+ <label
+ class="block text-[9px] font-bold text-gray-400 dark:text-zinc-500 uppercase tracking-widest mb-1.5"
>
- {{ generatedUri }}
+ LXMF URI
+ </label>
+ <div class="flex gap-2">
+ <div
+ class="flex-1 font-mono text-[10px] break-all text-gray-600 dark:text-zinc-300 bg-white dark:bg-zinc-900 p-2 rounded-lg border border-gray-200 dark:border-zinc-700 max-h-20 overflow-y-auto"
+ >
+ {{ generatedUri }}
+ </div>
+ <button
+ type="button"
+ class="size-9 flex items-center justify-center bg-white dark:bg-zinc-900 text-gray-500 dark:text-zinc-400 rounded-lg border border-gray-200 dark:border-zinc-700 hover:bg-blue-50 hover:text-blue-600 hover:border-blue-200 transition-all shadow-sm"
+ title="Copy URI"
+ @click="copyUri"
+ >
+ <MaterialDesignIcon icon-name="content-copy" class="size-4" />
+ </button>
</div>
+ </div>
+
+ <div class="flex gap-2 pt-1">
<button
type="button"
- class="size-9 flex items-center justify-center bg-white dark:bg-zinc-900 text-gray-500 dark:text-zinc-400 rounded-lg border border-gray-200 dark:border-zinc-700 hover:bg-blue-50 hover:text-blue-600 hover:border-blue-200 transition-all shadow-sm"
- title="Copy URI"
- @click="copyUri"
+ class="flex-1 flex items-center justify-center gap-2 py-2.5 px-4 bg-blue-600 hover:bg-blue-700 text-white rounded-xl font-bold shadow-lg shadow-blue-500/20 transition-all active:scale-[0.98] text-sm"
+ @click="printQRCode"
>
- <MaterialDesignIcon icon-name="content-copy" class="size-4" />
+ <MaterialDesignIcon icon-name="printer" class="size-4" />
+ Print
+ </button>
+ <button
+ type="button"
+ class="flex-1 flex items-center justify-center gap-2 py-2.5 px-4 bg-emerald-600 hover:bg-emerald-700 text-white rounded-xl font-bold shadow-lg shadow-emerald-500/20 transition-all active:scale-[0.98] text-sm"
+ :disabled="isSending"
+ @click="sendPaperMessage"
+ >
+ <template v-if="isSending">
+ <div
+ class="size-4 border-2 border-white/20 border-t-white rounded-full animate-spin"
+ ></div>
+ Sending...
+ </template>
+ <template v-else>
+ <MaterialDesignIcon icon-name="send" class="size-4" />
+ Send
+ </template>
</button>
</div>
</div>
-
- <div class="flex gap-2 pt-1">
- <button
- type="button"
- class="flex-1 flex items-center justify-center gap-2 py-2.5 px-4 bg-blue-600 hover:bg-blue-700 text-white rounded-xl font-bold shadow-lg shadow-blue-500/20 transition-all active:scale-[0.98] text-sm"
- @click="printQRCode"
- >
- <MaterialDesignIcon icon-name="printer" class="size-4" />
- Print
- </button>
- <button
- type="button"
- class="flex-1 flex items-center justify-center gap-2 py-2.5 px-4 bg-emerald-600 hover:bg-emerald-700 text-white rounded-xl font-bold shadow-lg shadow-emerald-500/20 transition-all active:scale-[0.98] text-sm"
- :disabled="isSending"
- @click="sendPaperMessage"
- >
- <template v-if="isSending">
- <div
- class="size-4 border-2 border-white/20 border-t-white rounded-full animate-spin"
- ></div>
- Sending...
- </template>
- <template v-else>
- <MaterialDesignIcon icon-name="send" class="size-4" />
- Send
- </template>
- </button>
- </div>
</div>
- </div>
- </section>
+ </section>
- <div
- v-else
- class="glass-card flex flex-col items-center justify-center p-8 text-center h-[320px] border-dashed"
- >
- <div class="p-3 bg-gray-100 dark:bg-zinc-800 text-gray-400 rounded-full mb-3">
- <MaterialDesignIcon icon-name="qrcode" class="size-10" />
+ <div
+ v-else
+ class="glass-card flex flex-col items-center justify-center p-8 text-center h-[320px] border-dashed"
+ >
+ <div class="p-3 bg-gray-100 dark:bg-zinc-800 text-gray-400 rounded-full mb-3">
+ <MaterialDesignIcon icon-name="qrcode" class="size-10" />
+ </div>
+ <h3 class="text-base font-bold text-gray-900 dark:text-white mb-1">No QR Code Generated</h3>
+ <p class="text-xs text-gray-500 dark:text-gray-400 max-w-[200px]">
+ Fill out the message details and click generate to create a signed paper message.
+ </p>
</div>
- <h3 class="text-base font-bold text-gray-900 dark:text-white mb-1">No QR Code Generated</h3>
- <p class="text-xs text-gray-500 dark:text-gray-400 max-w-[200px]">
- Fill out the message details and click generate to create a signed paper message.
- </p>
</div>
</div>
</div>
diff --git a/meshchatx/src/frontend/components/tools/RNPathPage.vue b/meshchatx/src/frontend/components/tools/RNPathPage.vue
index 588a6999..423b6ba0 100644
--- a/meshchatx/src/frontend/components/tools/RNPathPage.vue
+++ b/meshchatx/src/frontend/components/tools/RNPathPage.vue
@@ -45,17 +45,75 @@
<!-- path table -->
<div v-if="tab === 'table'" class="space-y-4">
+ <!-- filters -->
+ <div class="glass-card p-4 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
+ <div class="relative">
+ <input
+ v-model="searchQuery"
+ type="text"
+ placeholder="Search Hash or Via..."
+ class="input-field pl-10"
+ />
+ <MaterialDesignIcon
+ icon-name="magnify"
+ class="absolute left-3 top-1/2 -translate-y-1/2 size-5 text-gray-400"
+ />
+ </div>
+ <select v-model="filterInterface" class="input-field">
+ <option value="">All Interfaces</option>
+ <option v-for="iface in interfaces" :key="iface" :value="iface">
+ {{ iface }}
+ </option>
+ </select>
+ <div class="flex items-center gap-2">
+ <span class="text-xs font-semibold text-gray-500 uppercase min-w-fit">Hops:</span>
+ <input
+ v-model.number="filterHops"
+ type="number"
+ min="0"
+ max="128"
+ placeholder="Any"
+ class="input-field"
+ />
+ </div>
+ <div class="flex items-center justify-end gap-4">
+ <div class="flex flex-col items-end">
+ <span class="text-[10px] font-bold text-gray-400 uppercase">Total</span>
+ <span class="text-sm font-bold">{{ totalItems }}</span>
+ </div>
+ <div class="flex flex-col items-end">
+ <span class="text-[10px] font-bold text-green-500 uppercase">Responsive</span>
+ <span class="text-sm font-bold text-green-600 dark:text-green-400">{{
+ responsiveItems
+ }}</span>
+ </div>
+ <div class="flex flex-col items-end">
+ <span class="text-[10px] font-bold text-red-500 uppercase">Unresponsive</span>
+ <span class="text-sm font-bold text-red-600 dark:text-red-400">{{
+ unresponsiveItems
+ }}</span>
+ </div>
+ </div>
+ </div>
+
<div v-if="pathTable.length === 0" class="glass-card p-12 text-center text-gray-500">
- No paths currently known.
+ No paths found matching your criteria.
</div>
<div v-else class="grid gap-4">
<div
v-for="path in pathTable"
:key="path.hash"
- class="glass-card p-4 flex flex-col sm:flex-row sm:items-center justify-between gap-4"
+ class="glass-card p-4 flex flex-col sm:flex-row sm:items-center justify-between gap-4 border-l-4"
+ :class="[
+ path.state === 2
+ ? 'border-l-green-500'
+ : path.state === 1
+ ? 'border-l-red-500'
+ : 'border-l-gray-300 dark:border-l-zinc-700',
+ ]"
>
<div class="min-w-0">
- <div class="flex items-center gap-2 mb-1">
+ <div class="flex flex-wrap items-center gap-2 mb-1">
<span class="font-mono text-sm font-bold text-indigo-600 dark:text-indigo-400 truncate">
{{ path.hash }}
</span>
@@ -64,11 +122,28 @@
>
{{ path.hops }} {{ path.hops === 1 ? "hop" : "hops" }}
</span>
+ <span
+ class="px-2 py-0.5 text-[10px] font-bold rounded uppercase tracking-wider"
+ :class="getStateColor(path.state)"
+ >
+ {{ getStateText(path.state) }}
+ </span>
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 font-mono truncate">
via {{ path.via }} on {{ path.interface }}
</div>
- <div class="text-[10px] text-gray-400 mt-1">Expires: {{ formatDate(path.expires) }}</div>
+ <div class="flex flex-wrap gap-x-4 gap-y-1 mt-2 text-[10px]">
+ <div class="text-gray-400">
+ <span class="font-semibold uppercase">Last Updated:</span>
+ {{ path.timestamp ? formatDate(path.timestamp) : "Unknown" }}
+ </div>
+ <div class="text-gray-400">
+ <span class="font-semibold uppercase">Expires:</span> {{ formatDate(path.expires) }}
+ </div>
+ <div v-if="path.announce_hash" class="text-gray-400">
+ <span class="font-semibold uppercase">Announce Hash:</span> {{ path.announce_hash }}
+ </div>
+ </div>
</div>
<button
class="px-3 py-1.5 text-xs font-semibold text-red-600 hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20 rounded-lg transition-colors border border-red-200 dark:border-red-900/30"
@@ -78,6 +153,39 @@
</button>
</div>
</div>
+
+ <!-- pagination -->
+ <div v-if="totalPages > 1" class="flex items-center justify-between glass-card p-4">
+ <div class="flex items-center gap-2">
+ <button
+ class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-zinc-800 disabled:opacity-50 transition-colors"
+ :disabled="currentPage === 1"
+ @click="currentPage--"
+ >
+ <MaterialDesignIcon icon-name="chevron-left" class="size-5" />
+ </button>
+ <span class="text-sm font-medium"> Page {{ currentPage }} of {{ totalPages }} </span>
+ <button
+ class="p-2 rounded-lg hover:bg-gray-100 dark:hover:bg-zinc-800 disabled:opacity-50 transition-colors"
+ :disabled="currentPage === totalPages"
+ @click="currentPage++"
+ >
+ <MaterialDesignIcon icon-name="chevron-right" class="size-5" />
+ </button>
+ </div>
+ <div class="flex items-center gap-2">
+ <span class="text-xs text-gray-500 uppercase font-semibold">Show:</span>
+ <select
+ v-model="itemsPerPage"
+ class="bg-transparent border-none text-sm font-bold focus:ring-0 cursor-pointer"
+ >
+ <option :value="20">20</option>
+ <option :value="50">50</option>
+ <option :value="100">100</option>
+ <option :value="250">250</option>
+ </select>
+ </div>
+ </div>
</div>
<!-- announce rates -->
@@ -208,8 +316,40 @@ export default {
rateTable: [],
requestHash: "",
dropViaHash: "",
+ // Pagination & Filtering
+ searchQuery: "",
+ filterInterface: "",
+ filterHops: null,
+ currentPage: 1,
+ itemsPerPage: 50,
+ totalItems: 0,
+ responsiveItems: 0,
+ unresponsiveItems: 0,
+ interfaces: [],
};
},
+ computed: {
+ totalPages() {
+ return Math.ceil(this.totalItems / this.itemsPerPage);
+ },
+ },
+ watch: {
+ searchQuery() {
+ this.currentPage = 1;
+ this.refreshTable();
+ },
+ filterInterface() {
+ this.currentPage = 1;
+ this.refreshTable();
+ },
+ filterHops() {
+ this.currentPage = 1;
+ this.refreshTable();
+ },
+ currentPage() {
+ this.refreshTable();
+ },
+ },
mounted() {
this.refreshAll();
},
@@ -217,12 +357,17 @@ export default {
async refreshAll() {
this.isLoading = true;
try {
- const [pathRes, rateRes] = await Promise.all([
- window.axios.get("/api/v1/rnpath/table"),
+ const [pathRes, rateRes, ifaceRes] = await Promise.all([
+ this.fetchPathTable(),
window.axios.get("/api/v1/rnpath/rates"),
+ window.axios.get("/api/v1/reticulum/interfaces"),
]);
- this.pathTable = pathRes.data.table;
+ this.pathTable = pathRes.table;
+ this.totalItems = pathRes.total;
+ this.responsiveItems = pathRes.responsive;
+ this.unresponsiveItems = pathRes.unresponsive;
this.rateTable = rateRes.data.rates;
+ this.interfaces = Object.keys(ifaceRes.data.interfaces);
} catch (e) {
console.error(e);
ToastUtils.error("Failed to fetch path data");
@@ -230,6 +375,41 @@ export default {
this.isLoading = false;
}
},
+ async refreshTable() {
+ this.isLoading = true;
+ try {
+ const res = await this.fetchPathTable();
+ this.pathTable = res.table;
+ this.totalItems = res.total;
+ this.responsiveItems = res.responsive;
+ this.unresponsiveItems = res.unresponsive;
+ } catch (e) {
+ console.error(e);
+ } finally {
+ this.isLoading = false;
+ }
+ },
+ async fetchPathTable() {
+ const params = {
+ page: this.currentPage,
+ limit: this.itemsPerPage,
+ search: this.searchQuery || undefined,
+ interface: this.filterInterface || undefined,
+ hops: this.filterHops !== null ? this.filterHops : undefined,
+ };
+ const res = await window.axios.get("/api/v1/rnpath/table", { params });
+ return res.data;
+ },
+ getStateColor(state) {
+ if (state === 2) return "bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300";
+ if (state === 1) return "bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-300";
+ return "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-400";
+ },
+ getStateText(state) {
+ if (state === 2) return "RESPONSIVE";
+ if (state === 1) return "UNRESPONSIVE";
+ return "UNKNOWN";
+ },
async dropPath(hash) {
if (!(await DialogUtils.confirm(`Are you sure you want to drop the path to ${hash}?`))) {
return;
diff --git a/meshchatx/src/frontend/components/tools/RNodeFlasherPage.vue b/meshchatx/src/frontend/components/tools/RNodeFlasherPage.vue
index e06bc666..eaf4eb14 100644
--- a/meshchatx/src/frontend/components/tools/RNodeFlasherPage.vue
+++ b/meshchatx/src/frontend/components/tools/RNodeFlasherPage.vue
@@ -10,14 +10,21 @@
</div>
<div class="ml-auto flex items-center space-x-2">
+ <button
+ class="p-2 text-gray-500 hover:bg-gray-100 dark:hover:bg-zinc-800 rounded-lg transition-colors flex items-center gap-2 text-sm font-medium"
+ @click="showAdvanced = !showAdvanced"
+ >
+ <MaterialDesignIcon :icon-name="showAdvanced ? 'cog' : 'cog-outline'" class="size-5" />
+ <span class="hidden sm:inline">{{ showAdvanced ? "Simple" : "Advanced" }}</span>
+ </button>
<a
href="/rnode-flasher/index.html"
target="_blank"
class="p-2 text-gray-500 hover:bg-gray-100 dark:hover:bg-zinc-800 rounded-lg transition-colors flex items-center gap-2 text-sm font-medium"
- title="Open in new tab"
+ title="Open original flasher in new tab"
>
<MaterialDesignIcon icon-name="open-in-new" class="size-5" />
- <span class="hidden sm:inline">Open in new tab</span>
+ <span class="hidden sm:inline">Original</span>
</a>
<button
class="p-2 text-gray-500 hover:bg-gray-100 dark:hover:bg-zinc-800 rounded-lg transition-colors"
@@ -29,19 +36,1037 @@
</div>
<!-- content -->
- <div class="flex-1 min-h-0 relative">
- <iframe src="/rnode-flasher/index.html" class="w-full h-full border-0" allow="serial"></iframe>
+ <div class="flex-1 min-h-0 overflow-y-auto p-4 sm:p-6 space-y-6">
+ <!-- setup card: Select device & Firmware -->
+ <div
+ class="border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 rounded-2xl shadow-xl overflow-hidden"
+ >
+ <div class="grid grid-cols-1 md:grid-cols-2">
+ <!-- Left: Device Selection -->
+ <div class="p-6 border-b md:border-b-0 md:border-r border-gray-100 dark:border-zinc-800 space-y-4">
+ <div class="flex items-center gap-2 mb-2">
+ <div
+ class="p-2 bg-blue-100 dark:bg-blue-900/30 rounded-lg text-blue-600 dark:text-blue-400"
+ >
+ <MaterialDesignIcon icon-name="usb-port" class="size-5" />
+ </div>
+ <h2 class="font-bold text-gray-900 dark:text-zinc-100">
+ 1. {{ $t("tools.rnode_flasher.select_device") }}
+ </h2>
+ </div>
+
+ <div class="space-y-3">
+ <div class="space-y-1">
+ <label
+ class="text-xs font-semibold text-gray-500 dark:text-zinc-500 uppercase tracking-wider"
+ >{{ $t("tools.rnode_flasher.product") }}</label
+ >
+ <select
+ v-model="selectedProduct"
+ class="w-full bg-gray-50 dark:bg-zinc-800/50 border border-gray-200 dark:border-zinc-800 text-gray-900 dark:text-zinc-100 text-sm rounded-xl focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 px-4 py-2.5 transition-all"
+ >
+ <option :value="null" disabled>
+ {{ $t("tools.rnode_flasher.select_product") }}
+ </option>
+ <option v-for="product of products" :key="product.id" :value="product">
+ {{ product.name }}
+ </option>
+ </select>
+ </div>
+
+ <div class="space-y-1">
+ <label
+ class="text-xs font-semibold text-gray-500 dark:text-zinc-500 uppercase tracking-wider"
+ >{{ $t("tools.rnode_flasher.model") }}</label
+ >
+ <select
+ v-model="selectedModel"
+ :disabled="!selectedProduct"
+ class="w-full bg-gray-50 dark:bg-zinc-800/50 border border-gray-200 dark:border-zinc-800 text-gray-900 dark:text-zinc-100 text-sm rounded-xl focus:ring-2 focus:ring-blue-500/50 focus:border-blue-500 px-4 py-2.5 transition-all disabled:opacity-50"
+ >
+ <option :value="null" disabled>{{ $t("tools.rnode_flasher.select_model") }}</option>
+ <template v-if="selectedProduct">
+ <option v-for="model of selectedProduct.models" :key="model.id" :value="model">
+ {{ model.name }}
+ </option>
+ </template>
+ </select>
+ </div>
+
+ <button
+ v-if="selectedProduct?.platform === 0x70"
+ :disabled="isEnteringDfuMode"
+ class="w-full inline-flex items-center justify-center gap-2 rounded-xl bg-amber-100 dark:bg-amber-900/30 hover:bg-amber-200 dark:hover:bg-amber-900/40 px-4 py-2.5 text-sm font-bold text-amber-700 dark:text-amber-400 transition-colors disabled:opacity-50"
+ @click="enterDfuMode"
+ >
+ <v-progress-circular v-if="isEnteringDfuMode" indeterminate size="16" width="2" />
+ <span>{{
+ isEnteringDfuMode
+ ? $t("tools.rnode_flasher.entering_dfu_mode")
+ : $t("tools.rnode_flasher.enter_dfu_mode")
+ }}</span>
+ </button>
+ </div>
+ </div>
+
+ <!-- Right: Firmware Selection -->
+ <div class="p-6 space-y-4 bg-gray-50/50 dark:bg-zinc-900/50">
+ <div class="flex items-center gap-2 mb-2">
+ <div
+ class="p-2 bg-purple-100 dark:bg-purple-900/30 rounded-lg text-purple-600 dark:text-purple-400"
+ >
+ <MaterialDesignIcon icon-name="file-download" class="size-5" />
+ </div>
+ <h2 class="font-bold text-gray-900 dark:text-zinc-100">
+ 2. {{ $t("tools.rnode_flasher.select_firmware") }}
+ </h2>
+ </div>
+
+ <div class="space-y-4">
+ <!-- Auto-download section -->
+ <div
+ v-if="selectedProduct && selectedModel && recommendedFirmwareFilename"
+ class="p-4 rounded-xl border border-blue-100 dark:border-blue-900/30 bg-blue-50/50 dark:bg-blue-900/10 space-y-3"
+ >
+ <div class="text-xs font-bold text-blue-700 dark:text-blue-400 uppercase">
+ {{ $t("tools.rnode_flasher.download_recommended") }}
+ </div>
+ <div class="text-sm text-gray-600 dark:text-zinc-400 break-all font-mono">
+ {{ recommendedFirmwareFilename }}
+ </div>
+ <button
+ :disabled="isDownloadingFirmware || !latestRelease"
+ class="w-full inline-flex items-center justify-center gap-2 rounded-xl bg-blue-600 hover:bg-blue-700 px-4 py-2.5 text-sm font-bold text-white transition-colors disabled:opacity-50"
+ @click="downloadRecommendedFirmware"
+ >
+ <v-progress-circular
+ v-if="isDownloadingFirmware"
+ indeterminate
+ size="16"
+ width="2"
+ />
+ <MaterialDesignIcon v-else icon-name="cloud-download" class="size-4" />
+ <span>{{
+ isDownloadingFirmware
+ ? $t("tools.rnode_flasher.downloading")
+ : $t("tools.rnode_flasher.download_recommended")
+ }}</span>
+ </button>
+ </div>
+
+ <!-- Manual file pick -->
+ <div class="space-y-1">
+ <label
+ class="text-xs font-semibold text-gray-500 dark:text-zinc-500 uppercase tracking-wider"
+ >{{ $t("tools.rnode_flasher.select_firmware") }}</label
+ >
+ <input
+ ref="file"
+ type="file"
+ accept=".zip"
+ class="block w-full text-sm text-gray-900 dark:text-zinc-100 border border-gray-200 dark:border-zinc-800 rounded-xl cursor-pointer bg-white dark:bg-zinc-900 focus:outline-none file:mr-4 file:py-2.5 file:px-4 file:border-0 file:text-sm file:font-bold file:bg-zinc-200 dark:file:bg-zinc-700 file:text-zinc-700 dark:file:text-zinc-200 hover:file:bg-zinc-300 dark:hover:file:bg-zinc-600"
+ />
+ </div>
+
+ <div
+ v-if="flashError"
+ class="p-3 rounded-xl bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 text-xs text-red-600 dark:text-red-400"
+ >
+ {{ flashError }}
+ </div>
+
+ <button
+ :disabled="!selectedProduct || !selectedModel || isFlashing"
+ class="w-full inline-flex items-center justify-center gap-2 rounded-xl bg-green-600 hover:bg-green-700 px-4 py-3 text-sm font-bold text-white shadow-lg shadow-green-600/20 transition-all active:scale-[0.98] disabled:opacity-50"
+ @click="flash"
+ >
+ <MaterialDesignIcon v-if="!isFlashing" icon-name="flash" class="size-5" />
+ <v-progress-circular v-else indeterminate size="16" width="2" />
+ <span>{{
+ isFlashing
+ ? $t("tools.rnode_flasher.flashing", { percentage: flashingProgress })
+ : $t("tools.rnode_flasher.flash_now")
+ }}</span>
+ </button>
+
+ <div v-if="isFlashing" class="space-y-1.5 pt-2">
+ <v-progress-linear v-model="flashingProgress" color="green" height="8" rounded />
+ <div class="text-[10px] text-center text-gray-500 font-mono">{{ flashingStatus }}</div>
+ </div>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <!-- actions & tools card -->
+ <div
+ v-if="showAdvanced || isProvisioning || isSettingFirmwareHash"
+ class="border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 rounded-2xl shadow-xl p-6 space-y-6"
+ >
+ <!-- Provision & Finalize -->
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-6">
+ <div class="space-y-3">
+ <div class="flex items-center gap-2">
+ <h3 class="font-bold text-gray-900 dark:text-zinc-100">
+ 3. {{ $t("tools.rnode_flasher.step_provision") }}
+ </h3>
+ <MaterialDesignIcon icon-name="key-variant" class="size-4 text-zinc-400" />
+ </div>
+ <p class="text-xs text-gray-500 dark:text-zinc-500">
+ {{ $t("tools.rnode_flasher.provision_description") }}
+ </p>
+ <button
+ v-if="!isProvisioning"
+ :disabled="!selectedProduct || !selectedModel"
+ class="w-full inline-flex items-center justify-center gap-2 rounded-xl bg-blue-100 dark:bg-blue-900/30 hover:bg-blue-200 dark:hover:bg-blue-900/40 px-4 py-2.5 text-sm font-bold text-blue-700 dark:text-blue-400 transition-colors disabled:opacity-50"
+ @click="provision"
+ >
+ {{ $t("tools.rnode_flasher.provision") }}
+ </button>
+ <div v-else class="flex items-center justify-center gap-2 text-sm text-blue-600 p-2">
+ <v-progress-circular indeterminate size="18" width="2" />
+ <span class="font-bold">{{ $t("tools.rnode_flasher.provisioning_wait") }}</span>
+ </div>
+ </div>
+
+ <div class="space-y-3">
+ <div class="flex items-center gap-2">
+ <h3 class="font-bold text-gray-900 dark:text-zinc-100">
+ 4. {{ $t("tools.rnode_flasher.step_set_hash") }}
+ </h3>
+ <MaterialDesignIcon icon-name="shield-check" class="size-4 text-zinc-400" />
+ </div>
+ <p class="text-xs text-gray-500 dark:text-zinc-500">
+ {{ $t("tools.rnode_flasher.set_hash_description") }}
+ </p>
+ <button
+ v-if="!isSettingFirmwareHash"
+ class="w-full inline-flex items-center justify-center gap-2 rounded-xl bg-blue-100 dark:bg-blue-900/30 hover:bg-blue-200 dark:hover:bg-blue-900/40 px-4 py-2.5 text-sm font-bold text-blue-700 dark:text-blue-400 transition-colors"
+ @click="setFirmwareHash"
+ >
+ {{ $t("tools.rnode_flasher.set_firmware_hash") }}
+ </button>
+ <div v-else class="flex items-center justify-center gap-2 text-sm text-blue-600 p-2">
+ <v-progress-circular indeterminate size="18" width="2" />
+ <span class="font-bold">{{ $t("tools.rnode_flasher.setting_hash_wait") }}</span>
+ </div>
+ </div>
+ </div>
+
+ <!-- Advanced Actions Grid -->
+ <div v-if="showAdvanced" class="pt-6 border-t border-gray-100 dark:border-zinc-800">
+ <div class="text-xs font-bold text-gray-400 dark:text-zinc-600 uppercase mb-4 tracking-widest">
+ {{ $t("tools.rnode_flasher.advanced_tools") }}
+ </div>
+ <div class="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-5 gap-3">
+ <button class="action-btn" @click="detect">
+ <MaterialDesignIcon icon-name="magnify" class="size-4" />
+ <span>{{ $t("tools.rnode_flasher.detect_rnode") }}</span>
+ </button>
+ <button class="action-btn" @click="reboot">
+ <MaterialDesignIcon icon-name="restart" class="size-4" />
+ <span>{{ $t("tools.rnode_flasher.reboot_rnode") }}</span>
+ </button>
+ <button class="action-btn" @click="readDisplay">
+ <MaterialDesignIcon icon-name="monitor" class="size-4" />
+ <span>{{ $t("tools.rnode_flasher.read_display") }}</span>
+ </button>
+ <button class="action-btn" @click="dumpEeprom">
+ <MaterialDesignIcon icon-name="database-export" class="size-4" />
+ <span>{{ $t("tools.rnode_flasher.dump_eeprom") }}</span>
+ </button>
+ <button class="action-btn danger" @click="wipeEeprom">
+ <MaterialDesignIcon icon-name="eraser" class="size-4" />
+ <span>{{ $t("tools.rnode_flasher.wipe_eeprom") }}</span>
+ </button>
+ </div>
+
+ <div
+ v-if="rnodeDisplayImage"
+ class="mt-4 p-4 rounded-2xl bg-zinc-950 flex justify-center border border-zinc-800"
+ >
+ <img :src="rnodeDisplayImage" class="h-28 pixelated" />
+ </div>
+ </div>
+ </div>
+
+ <!-- config cards -->
+ <div v-if="showAdvanced" class="grid grid-cols-1 lg:grid-cols-2 gap-6">
+ <!-- Bluetooth -->
+ <div
+ class="border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 rounded-2xl shadow-xl overflow-hidden"
+ >
+ <div class="px-6 py-4 border-b border-gray-100 dark:border-zinc-800 flex items-center gap-2">
+ <MaterialDesignIcon icon-name="bluetooth" class="size-5 text-blue-500" />
+ <h3 class="font-bold text-gray-900 dark:text-zinc-100">
+ {{ $t("tools.rnode_flasher.configure_bluetooth") }}
+ </h3>
+ </div>
+ <div class="p-6 space-y-4">
+ <div class="flex flex-wrap gap-2">
+ <button class="action-btn flex-1" @click="enableBluetooth">
+ {{ $t("tools.rnode_flasher.enable") }}
+ </button>
+ <button class="action-btn flex-1" @click="disableBluetooth">
+ {{ $t("tools.rnode_flasher.disable") }}
+ </button>
+ <button
+ class="action-btn flex-[2] bg-blue-500 !text-white !border-none"
+ @click="startBluetoothPairing"
+ >
+ {{ $t("tools.rnode_flasher.start_pairing") }}
+ </button>
+ </div>
+ <div
+ class="text-[10px] text-gray-400 dark:text-zinc-500 leading-relaxed uppercase tracking-wider"
+ >
+ {{ $t("tools.rnode_flasher.bluetooth_restart_warning") }}
+ </div>
+ </div>
+ </div>
+
+ <!-- TNC -->
+ <div
+ class="border border-gray-200 dark:border-zinc-800 bg-white dark:bg-zinc-900 rounded-2xl shadow-xl overflow-hidden"
+ >
+ <div class="px-6 py-4 border-b border-gray-100 dark:border-zinc-800 flex items-center gap-2">
+ <MaterialDesignIcon icon-name="radio-tower" class="size-5 text-green-500" />
+ <h3 class="font-bold text-gray-900 dark:text-zinc-100">
+ {{ $t("tools.rnode_flasher.configure_tnc") }}
+ </h3>
+ </div>
+ <div class="p-6 space-y-4">
+ <div class="grid grid-cols-2 gap-3">
+ <div class="space-y-1">
+ <label class="text-[10px] font-bold text-zinc-400 uppercase tracking-widest">{{
+ $t("tools.rnode_flasher.frequency")
+ }}</label>
+ <input v-model="configFrequency" type="number" class="config-input" />
+ </div>
+ <div class="space-y-1">
+ <label class="text-[10px] font-bold text-zinc-400 uppercase tracking-widest">{{
+ $t("tools.rnode_flasher.tx_power")
+ }}</label>
+ <input v-model="configTxPower" type="number" class="config-input" />
+ </div>
+ <div class="space-y-1">
+ <label class="text-[10px] font-bold text-zinc-400 uppercase tracking-widest">{{
+ $t("tools.rnode_flasher.bandwidth")
+ }}</label>
+ <select v-model="configBandwidth" class="config-input">
+ <option v-for="bw in RNodeInterfaceDefaults.bandwidths" :key="bw" :value="bw">
+ {{ bw / 1000 }} KHz
+ </option>
+ </select>
+ </div>
+ <div class="space-y-1">
+ <label class="text-[10px] font-bold text-zinc-400 uppercase tracking-widest">{{
+ $t("tools.rnode_flasher.spreading_factor")
+ }}</label>
+ <select v-model="configSpreadingFactor" class="config-input">
+ <option v-for="sf in RNodeInterfaceDefaults.spreadingfactors" :key="sf" :value="sf">
+ {{ sf }}
+ </option>
+ </select>
+ </div>
+ </div>
+ <div class="flex gap-2">
+ <button
+ class="action-btn flex-1 bg-green-600 !text-white !border-none"
+ @click="enableTncMode"
+ >
+ {{ $t("tools.rnode_flasher.enable") }}
+ </button>
+ <button class="action-btn flex-1" @click="disableTncMode">
+ {{ $t("tools.rnode_flasher.disable") }}
+ </button>
+ </div>
+ </div>
+ </div>
+ </div>
+
+ <!-- help footer -->
+ <div
+ class="flex flex-col sm:flex-row items-center justify-between gap-4 p-4 border border-zinc-200 dark:border-zinc-800 rounded-2xl bg-zinc-50 dark:bg-zinc-900/30"
+ >
+ <div class="flex items-center gap-3 text-sm text-zinc-500">
+ <MaterialDesignIcon icon-name="help-circle-outline" class="size-5" />
+ <span>{{ $t("tools.rnode_flasher.find_device_issue") }}</span>
+ </div>
+ <div class="flex items-center gap-4">
+ <a
+ target="_blank"
+ href="https://github.com/liamcottle/rnode-flasher"
+ class="text-blue-500 hover:underline text-sm font-bold"
+ >RNode Flasher GH</a
+ >
+ <a
+ target="_blank"
+ href="https://github.com/markqvist/RNode_Firmware"
+ class="text-blue-500 hover:underline text-sm font-bold"
+ >RNode Firmware GH</a
+ >
+ </div>
+ </div>
</div>
</div>
</template>
<script>
import MaterialDesignIcon from "../MaterialDesignIcon.vue";
+import RNode from "../../js/rnode/RNode.js";
+import ROM from "../../js/rnode/ROM.js";
+import Nrf52DfuFlasher from "../../js/rnode/Nrf52DfuFlasher.js";
+import RNodeUtils from "../../js/rnode/RNodeUtils.js";
+import products from "../../js/rnode/products.js";
+import ToastUtils from "../../js/ToastUtils.js";
export default {
name: "RNodeFlasherPage",
components: {
MaterialDesignIcon,
},
+ data() {
+ return {
+ rnode: null,
+ isFlashing: false,
+ flashingProgress: 0,
+ flashingStatus: "",
+ flashError: null,
+ isProvisioning: false,
+ isSettingFirmwareHash: false,
+ isEnteringDfuMode: false,
+ rnodeDisplayImage: null,
+ showAdvanced: false,
+ selectedProduct: null,
+ selectedModel: null,
+ products: products,
+ configFrequency: 917375000,
+ configBandwidth: 250000,
+ configTxPower: 22,
+ configSpreadingFactor: 11,
+ configCodingRate: 5,
+ latestRelease: null,
+ isDownloadingFirmware: false,
+ RNodeInterfaceDefaults: {
+ bandwidths: [7800, 10400, 15600, 20800, 31250, 41700, 62500, 125000, 250000, 500000],
+ codingrates: [5, 6, 7, 8],
+ spreadingfactors: [7, 8, 9, 10, 11, 12],
+ },
+ };
+ },
+ computed: {
+ recommendedFirmwareFilename() {
+ return this.selectedModel?.firmware_filename ?? this.selectedProduct?.firmware_filename;
+ },
+ },
+ watch: {
+ selectedProduct() {
+ this.selectedModel = null;
+ },
+ },
+ mounted() {
+ this.loadVendorLibraries();
+ this.fetchLatestRelease();
+ },
+ methods: {
+ async fetchLatestRelease() {
+ try {
+ const response = await fetch("https://api.github.com/repos/markqvist/RNode_Firmware/releases/latest");
+ if (response.ok) {
+ this.latestRelease = await response.json();
+ }
+ } catch {
+ // ignore
+ }
+ },
+ async downloadRecommendedFirmware() {
+ if (!this.recommendedFirmwareFilename || !this.latestRelease) return;
+
+ const asset = this.latestRelease.assets.find((a) => a.name === this.recommendedFirmwareFilename);
+ if (!asset) {
+ ToastUtils.error(this.$t("tools.rnode_flasher.errors.firmware_not_found_in_release"));
+ return;
+ }
+
+ this.isDownloadingFirmware = true;
+ try {
+ const downloadUrl = `/api/v1/tools/rnode/download_firmware?url=${encodeURIComponent(asset.browser_download_url)}`;
+ const response = await fetch(downloadUrl);
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({ error: response.statusText }));
+ throw new Error(errorData.error || `Download failed with status ${response.status}`);
+ }
+ const blob = await response.blob();
+ const file = new File([blob], asset.name, { type: "application/zip" });
+
+ // Manually set the file to the input ref
+ const dataTransfer = new DataTransfer();
+ dataTransfer.items.add(file);
+ this.$refs["file"].files = dataTransfer.files;
+
+ ToastUtils.success(this.$t("tools.rnode_flasher.alerts.firmware_downloaded"));
+ } catch (e) {
+ ToastUtils.error(this.$t("tools.rnode_flasher.errors.failed_download", { error: e.message || e }));
+ } finally {
+ this.isDownloadingFirmware = false;
+ }
+ },
+ async loadVendorLibraries() {
+ // Check if libraries are already loaded
+ if (window.zip && window.CryptoJS && window.ESPLoader) return;
+
+ const libs = [
+ "/rnode-flasher/js/zip.min.js",
+ "/rnode-flasher/js/crypto-js@3.9.1-1/core.js",
+ "/rnode-flasher/js/crypto-js@3.9.1-1/md5.js",
+ ];
+
+ for (const lib of libs) {
+ await this.loadScript(lib);
+ }
+
+ try {
+ // Load ES Modules
+ const esptoolPath = "/rnode-flasher/js/esptool-js@0.4.5/bundle.js";
+ const esptool = await import(/* @vite-ignore */ esptoolPath);
+ window.ESPLoader = esptool.ESPLoader;
+ window.Transport = esptool.Transport;
+
+ const serialPolyfillPath = "/rnode-flasher/js/web-serial-polyfill@1.0.15/dist/serial.js";
+ const serialPolyfill = await import(/* @vite-ignore */ serialPolyfillPath);
+ if (serialPolyfill.serial) {
+ window.serial = serialPolyfill.serial;
+ }
+ } catch (e) {
+ console.error("Failed to load ES module vendor libraries:", e);
+ }
+
+ // Setup polyfill
+ if (!navigator.serial && navigator.usb && window.serial) {
+ navigator.serial = window.serial;
+ }
+ },
+ loadScript(src) {
+ return new Promise((resolve, reject) => {
+ const script = document.createElement("script");
+ script.src = src;
+ script.onload = resolve;
+ script.onerror = reject;
+ document.head.appendChild(script);
+ });
+ },
+ async askForSerialPort() {
+ if (!navigator.serial) {
+ this.flashError = this.$t("tools.rnode_flasher.errors.web_serial_not_supported");
+ ToastUtils.error(this.flashError);
+ return null;
+ }
+
+ if (this.rnode) {
+ try {
+ await this.rnode.close();
+ } catch {
+ // ignore
+ }
+ this.rnode = null;
+ }
+
+ try {
+ return await navigator.serial.requestPort({ filters: [] });
+ } catch (e) {
+ if (e.name === "NotFoundError" || e.message?.includes("No port selected")) {
+ this.flashError = this.$t("tools.rnode_flasher.errors.no_device_selected");
+ } else {
+ this.flashError = this.$t("tools.rnode_flasher.errors.failed_connect", { error: e.message || e });
+ }
+ throw e;
+ }
+ },
+ async askForRNode() {
+ try {
+ const serialPort = await this.askForSerialPort();
+ if (!serialPort) return false;
+
+ this.flashingStatus = this.$t("tools.rnode_flasher.connecting_device");
+ this.rnode = await RNode.fromSerialPort(serialPort);
+ const isRNode = await this.rnode.detect();
+ if (!isRNode) {
+ await this.rnode.close();
+ this.flashError = this.$t("tools.rnode_flasher.errors.not_an_rnode");
+ ToastUtils.error(this.flashError);
+ this.flashingStatus = "";
+ return false;
+ }
+
+ this.flashingStatus = "";
+ return this.rnode;
+ } catch (e) {
+ this.flashingStatus = "";
+ throw e;
+ }
+ },
+ async enterDfuMode() {
+ this.isEnteringDfuMode = true;
+ this.flashError = null;
+
+ try {
+ const serialPort = await this.askForSerialPort();
+ if (!serialPort) return;
+
+ const flasher = new Nrf52DfuFlasher(serialPort);
+ await flasher.enterDfuMode();
+
+ ToastUtils.success(this.$t("tools.rnode_flasher.alerts.dfu_ready"));
+ } catch (e) {
+ this.flashError = this.$t("tools.rnode_flasher.errors.failed_dfu", { error: e.message || e });
+ ToastUtils.error(this.flashError);
+ } finally {
+ this.isEnteringDfuMode = false;
+ }
+ },
+ async flash() {
+ switch (this.selectedProduct?.platform) {
+ case ROM.PLATFORM_ESP32:
+ await this.flashEsp32();
+ break;
+ case ROM.PLATFORM_NRF52:
+ await this.flashNrf52();
+ break;
+ default:
+ ToastUtils.error(this.$t("tools.rnode_flasher.errors.select_product_first"));
+ break;
+ }
+ },
+ async flashNrf52() {
+ this.flashError = null;
+ const file = this.$refs["file"].files[0];
+ if (!file) {
+ this.flashError = this.$t("tools.rnode_flasher.errors.select_firmware_first");
+ ToastUtils.error(this.flashError);
+ return;
+ }
+
+ let serialPort = null;
+ try {
+ serialPort = await this.askForSerialPort();
+ if (!serialPort) return;
+
+ this.isFlashing = true;
+ this.flashingProgress = 0;
+ this.flashingStatus = this.$t("tools.rnode_flasher.connecting_device");
+
+ const flasher = new Nrf52DfuFlasher(serialPort);
+ await flasher.flash(file, (percentage, message) => {
+ this.flashingProgress = percentage;
+ this.flashingStatus = message || this.$t("tools.rnode_flasher.flashing", { percentage });
+ });
+
+ ToastUtils.success(this.$t("tools.rnode_flasher.alerts.flash_success"));
+ } catch (e) {
+ this.flashError = this.$t("tools.rnode_flasher.errors.failed_flash", { error: e.message || e });
+ ToastUtils.error(this.flashError);
+ } finally {
+ this.isFlashing = false;
+ this.flashingStatus = "";
+ if (serialPort) await serialPort.close().catch(() => {});
+ }
+ },
+ async flashEsp32() {
+ this.flashError = null;
+ if (!window.ESPLoader) {
+ this.flashError = this.$t("tools.rnode_flasher.errors.esptool_not_loaded");
+ ToastUtils.error(this.flashError);
+ return;
+ }
+
+ const flashConfig = this.selectedModel?.flash_config ?? this.selectedProduct?.flash_config;
+ if (!flashConfig) {
+ this.flashError = this.$t("tools.rnode_flasher.errors.no_flash_config");
+ ToastUtils.error(this.flashError);
+ return;
+ }
+
+ const file = this.$refs["file"].files[0];
+ if (!file) {
+ this.flashError = this.$t("tools.rnode_flasher.errors.select_firmware_first");
+ ToastUtils.error(this.flashError);
+ return;
+ }
+
+ let serialPort = null;
+ try {
+ serialPort = await this.askForSerialPort();
+ if (!serialPort) return;
+
+ this.isFlashing = true;
+ this.flashingProgress = 0;
+ this.flashingStatus = this.$t("tools.rnode_flasher.connecting_device");
+
+ const blobReader = new window.zip.BlobReader(file);
+ const zipReader = new window.zip.ZipReader(blobReader);
+ const zipEntries = await zipReader.getEntries();
+
+ const filesToFlash = [];
+ for (const [address, filename] of Object.entries(flashConfig.flash_files)) {
+ const entry = zipEntries.find((e) => e.filename === filename);
+ if (!entry)
+ throw new Error(this.$t("tools.rnode_flasher.errors.failed_extract", { file: filename }));
+ const blob = await entry.getData(new window.zip.BlobWriter());
+ filesToFlash.push({
+ address: parseInt(address),
+ data: await this.readAsBinaryString(blob),
+ });
+ }
+
+ const transport = new window.Transport(serialPort, true);
+ const esploader = new window.ESPLoader({
+ transport,
+ baudrate: 921600,
+ terminal: {
+ writeLine: console.log,
+ write: console.log,
+ },
+ });
+
+ await esploader.main();
+ await esploader.writeFlash({
+ fileArray: filesToFlash,
+ flashSize: flashConfig.flash_size,
+ flashMode: "DIO",
+ flashFreq: "80MHz",
+ calculateMD5Hash: (img) => window.CryptoJS.MD5(window.CryptoJS.enc.Latin1.parse(img)),
+ reportProgress: (idx, written, total) => {
+ this.flashingProgress = Math.floor((written / total) * 100);
+ this.flashingStatus = `File ${idx + 1}/${filesToFlash.length}: ${this.flashingProgress}%`;
+ },
+ });
+
+ // Reboot
+ await transport.setDTR(false);
+ await new Promise((r) => setTimeout(r, 100));
+ await transport.setDTR(true);
+
+ ToastUtils.success(this.$t("tools.rnode_flasher.alerts.flash_success"));
+ } catch (e) {
+ this.flashError = this.$t("tools.rnode_flasher.errors.failed_flash", { error: e.message || e });
+ ToastUtils.error(this.flashError);
+ } finally {
+ this.isFlashing = false;
+ this.flashingStatus = "";
+ if (serialPort) await serialPort.close().catch(() => {});
+ }
+ },
+ async detect() {
+ try {
+ const rnode = await this.askForRNode();
+ if (!rnode) return;
+ const ver = await rnode.getFirmwareVersion();
+ ToastUtils.success(`RNode v${ver} detected`);
+ await rnode.close();
+ } catch {
+ ToastUtils.error(this.$t("tools.rnode_flasher.errors.failed_detect"));
+ }
+ },
+ async reboot() {
+ try {
+ const rnode = await this.askForRNode();
+ if (!rnode) return;
+ await rnode.reset();
+ await rnode.close();
+ ToastUtils.success(this.$t("tools.rnode_flasher.alerts.rebooting"));
+ } catch {
+ // ignore
+ }
+ },
+ async readDisplay() {
+ try {
+ const rnode = await this.askForRNode();
+ if (!rnode) return;
+ const buffer = await rnode.readDisplay();
+ await rnode.close();
+ this.rnodeDisplayImage = this.rnodeDisplayBufferToPng(buffer);
+ } catch {
+ // ignore
+ }
+ },
+ rnodeDisplayBufferToPng(displayBuffer) {
+ const displayArea = displayBuffer.slice(0, 512);
+ const statArea = displayBuffer.slice(512, 1024);
+
+ const displayCanvas = this.frameBufferToCanvas(displayArea, 64, 64, "#000000", "#FFFFFF");
+ const statCanvas = this.frameBufferToCanvas(statArea, 64, 64, "#000000", "#FFFFFF");
+
+ const canvas = document.createElement("canvas");
+ canvas.width = 128;
+ canvas.height = 64;
+ const ctx = canvas.getContext("2d");
+ ctx.drawImage(displayCanvas, 0, 0);
+ ctx.drawImage(statCanvas, 64, 0);
+
+ const scaledCanvas = document.createElement("canvas");
+ scaledCanvas.width = 512;
+ scaledCanvas.height = 256;
+ const sCtx = scaledCanvas.getContext("2d");
+ sCtx.imageSmoothingEnabled = false;
+ sCtx.drawImage(canvas, 0, 0, 512, 256);
+
+ return scaledCanvas.toDataURL("image/png");
+ },
+ frameBufferToCanvas(fb, w, h, bg, fg) {
+ const c = document.createElement("canvas");
+ c.width = w;
+ c.height = h;
+ const ctx = c.getContext("2d");
+ ctx.fillStyle = bg;
+ ctx.fillRect(0, 0, w, h);
+ ctx.fillStyle = fg;
+ for (let y = 0; y < h; y++) {
+ for (let x = 0; x < w; x++) {
+ const idx = Math.floor((y * w + x) / 8);
+ const bit = (fb[idx] >> (7 - (x % 8))) & 1;
+ if (bit) ctx.fillRect(x, y, 1, 1);
+ }
+ }
+ return c;
+ },
+ async dumpEeprom() {
+ try {
+ const rnode = await this.askForRNode();
+ if (!rnode) return;
+ const eeprom = await rnode.getRom();
+ console.log(RNodeUtils.bytesToHex(eeprom));
+ await rnode.close();
+ ToastUtils.success("EEPROM dumped to console");
+ } catch {
+ // ignore
+ }
+ },
+ async wipeEeprom() {
+ try {
+ const rnode = await this.askForRNode();
+ if (!rnode) return;
+ if (!confirm(this.$t("tools.rnode_flasher.alerts.eeprom_wipe_confirm"))) {
+ await rnode.close();
+ return;
+ }
+ await rnode.wipeRom();
+ await rnode.reset();
+ await rnode.close();
+ ToastUtils.success(this.$t("tools.rnode_flasher.alerts.eeprom_wiped"));
+ } catch {
+ // ignore
+ }
+ },
+ async provision() {
+ try {
+ const rnode = await this.askForRNode();
+ if (!rnode) return;
+ const rom = await rnode.getRomAsObject();
+ if (rom.parse()) {
+ ToastUtils.error(this.$t("tools.rnode_flasher.errors.provisioned_already"));
+ await rnode.close();
+ return;
+ }
+ if (!this.selectedProduct || !this.selectedModel) {
+ ToastUtils.error(this.$t("tools.rnode_flasher.errors.select_product_first"));
+ await rnode.close();
+ return;
+ }
+
+ this.isProvisioning = true;
+ const product = this.selectedProduct.id;
+ const model = this.selectedModel.mapped_id ?? this.selectedModel.id;
+ const hwRev = 0x1;
+ const serial = 1;
+ const now = Math.floor(Date.now() / 1000);
+ const sBytes = RNodeUtils.packUInt32BE(serial);
+ const tBytes = RNodeUtils.packUInt32BE(now);
+ const checksum = RNodeUtils.md5([product, model, hwRev, ...sBytes, ...tBytes]);
+
+ await rnode.writeRom(ROM.ADDR_PRODUCT, product);
+ await rnode.writeRom(ROM.ADDR_MODEL, model);
+ await rnode.writeRom(ROM.ADDR_HW_REV, hwRev);
+ await rnode.writeRom(ROM.ADDR_SERIAL, sBytes[0]);
+ await rnode.writeRom(ROM.ADDR_SERIAL + 1, sBytes[1]);
+ await rnode.writeRom(ROM.ADDR_SERIAL + 2, sBytes[2]);
+ await rnode.writeRom(ROM.ADDR_SERIAL + 3, sBytes[3]);
+ await rnode.writeRom(ROM.ADDR_MADE, tBytes[0]);
+ await rnode.writeRom(ROM.ADDR_MADE + 1, tBytes[1]);
+ await rnode.writeRom(ROM.ADDR_MADE + 2, tBytes[2]);
+ await rnode.writeRom(ROM.ADDR_MADE + 3, tBytes[3]);
+
+ for (let i = 0; i < 16; i++) await rnode.writeRom(ROM.ADDR_CHKSUM + i, checksum[i]);
+ for (let i = 0; i < 128; i++) await rnode.writeRom(ROM.ADDR_SIGNATURE + i, 0x00);
+ await rnode.writeRom(ROM.ADDR_INFO_LOCK, ROM.INFO_LOCK_BYTE);
+
+ await RNodeUtils.sleepMillis(5000);
+ await rnode.reset();
+ await rnode.close();
+ ToastUtils.success(this.$t("tools.rnode_flasher.alerts.provision_success"));
+ } catch {
+ ToastUtils.error(this.$t("tools.rnode_flasher.errors.failed_provision"));
+ } finally {
+ this.isProvisioning = false;
+ }
+ },
+ async setFirmwareHash() {
+ try {
+ const rnode = await this.askForRNode();
+ if (!rnode) return;
+ const rom = await rnode.getRomAsObject();
+ if (!rom.parse()) {
+ ToastUtils.error(this.$t("tools.rnode_flasher.errors.not_provisioned"));
+ await rnode.close();
+ return;
+ }
+
+ this.isSettingFirmwareHash = true;
+ const hash = await rnode.getFirmwareHash();
+ await rnode.setFirmwareHash(hash);
+ await RNodeUtils.sleepMillis(5000);
+ await rnode.reset().catch(() => {
+ // ignore
+ });
+ await rnode.close();
+ ToastUtils.success(this.$t("tools.rnode_flasher.alerts.hash_success"));
+ } catch {
+ ToastUtils.error(this.$t("tools.rnode_flasher.errors.failed_set_hash"));
+ } finally {
+ this.isSettingFirmwareHash = false;
+ }
+ },
+ async enableTncMode() {
+ try {
+ const rnode = await this.askForRNode();
+ if (!rnode) return;
+ await rnode.setFrequency(this.configFrequency);
+ await rnode.setBandwidth(this.configBandwidth);
+ await rnode.setTxPower(this.configTxPower);
+ await rnode.setSpreadingFactor(this.configSpreadingFactor);
+ await rnode.setCodingRate(this.configCodingRate);
+ await rnode.setRadioStateOn();
+ await RNodeUtils.sleepMillis(500);
+ await rnode.saveConfig();
+ await rnode.saveConfig();
+ await RNodeUtils.sleepMillis(5000);
+ await rnode.reset();
+ await rnode.close();
+ ToastUtils.success(this.$t("tools.rnode_flasher.alerts.tnc_enabled"));
+ } catch {
+ // ignore
+ }
+ },
+ async disableTncMode() {
+ try {
+ const rnode = await this.askForRNode();
+ if (!rnode) return;
+ await rnode.deleteConfig();
+ await RNodeUtils.sleepMillis(5000);
+ await rnode.reset();
+ await rnode.close();
+ ToastUtils.success(this.$t("tools.rnode_flasher.alerts.tnc_disabled"));
+ } catch {
+ // ignore
+ }
+ },
+ async enableBluetooth() {
+ try {
+ const rnode = await this.askForRNode();
+ if (!rnode) return;
+ await rnode.enableBluetooth();
+ await RNodeUtils.sleepMillis(1000);
+ await rnode.close();
+ ToastUtils.success(this.$t("tools.rnode_flasher.alerts.bluetooth_enabled"));
+ } catch {
+ // ignore
+ }
+ },
+ async disableBluetooth() {
+ try {
+ const rnode = await this.askForRNode();
+ if (!rnode) return;
+ await rnode.disableBluetooth();
+ await RNodeUtils.sleepMillis(1000);
+ await rnode.close();
+ ToastUtils.success(this.$t("tools.rnode_flasher.alerts.bluetooth_disabled"));
+ } catch {
+ // ignore
+ }
+ },
+ async startBluetoothPairing() {
+ try {
+ const rnode = await this.askForRNode();
+ if (!rnode) return;
+ await rnode.startBluetoothPairing((pin) => {
+ ToastUtils.success(this.$t("tools.rnode_flasher.alerts.bluetooth_pairing_pin", { pin }));
+ });
+ ToastUtils.success("Pairing mode started (30s)");
+ } catch {
+ // ignore
+ }
+ },
+ async setDisplayRotation(rot) {
+ try {
+ const rnode = await this.askForRNode();
+ if (!rnode) return;
+ await rnode.setDisplayRotation(rot);
+ await rnode.close();
+ ToastUtils.success("Rotation updated");
+ } catch {
+ // ignore
+ }
+ },
+ async startDisplayReconditioning() {
+ try {
+ const rnode = await this.askForRNode();
+ if (!rnode) return;
+ await rnode.startDisplayReconditioning();
+ await rnode.close();
+ ToastUtils.success("Reconditioning started");
+ } catch {
+ // ignore
+ }
+ },
+ async readAsBinaryString(blob) {
+ return new Promise((resolve) => {
+ const r = new FileReader();
+ r.onload = () => resolve(r.result);
+ r.readAsBinaryString(blob);
+ });
+ },
+ },
};
</script>
+
+<style scoped>
+.action-btn {
+ @apply inline-flex items-center justify-center gap-2 rounded-xl bg-gray-100 dark:bg-zinc-800 hover:bg-gray-200 dark:hover:bg-zinc-700 px-3 py-2 text-[11px] font-bold text-gray-700 dark:text-zinc-300 border border-gray-200 dark:border-zinc-700 transition-all active:scale-95;
+}
+
+.action-btn.danger {
+ @apply bg-red-50 dark:bg-red-900/20 text-red-600 dark:text-red-400 border-red-100 dark:border-red-900/30 hover:bg-red-100 dark:hover:bg-red-900/40;
+}
+
+.config-input {
+ @apply w-full bg-gray-50 dark:bg-zinc-800 border border-gray-200 dark:border-zinc-800 text-gray-900 dark:text-zinc-100 text-[11px] rounded-lg focus:ring-1 focus:ring-blue-500/50 focus:border-blue-500 px-3 py-2 transition-all;
+}
+
+.pixelated {
+ image-rendering: pixelated;
+}
+
+select {
+ appearance: none;
+ background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
+ background-position: right 0.5rem center;
+ background-repeat: no-repeat;
+ background-size: 1.5em 1.5em;
+}
+
+input[type="number"]::-webkit-inner-spin-button,
+input[type="number"]::-webkit-outer-spin-button {
+ -webkit-appearance: none;
+ margin: 0;
+}
+</style>
diff --git a/meshchatx/src/frontend/components/tools/ToolsPage.vue b/meshchatx/src/frontend/components/tools/ToolsPage.vue
index 389ceae1..e68e4d57 100644
--- a/meshchatx/src/frontend/components/tools/ToolsPage.vue
+++ b/meshchatx/src/frontend/components/tools/ToolsPage.vue
@@ -2,193 +2,199 @@
<div
class="flex flex-col flex-1 overflow-hidden min-w-0 bg-gradient-to-br from-slate-50 via-slate-100 to-white dark:from-zinc-950 dark:via-zinc-900 dark:to-zinc-900"
>
- <div class="overflow-y-auto space-y-4 p-4 md:p-6 max-w-5xl mx-auto w-full">
- <div class="glass-card space-y-3">
- <div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
- {{ $t("tools.utilities") }}
- </div>
- <div class="text-2xl font-semibold text-gray-900 dark:text-white">{{ $t("tools.power_tools") }}</div>
- <div class="text-sm text-gray-600 dark:text-gray-300">
- {{ $t("tools.diagnostics_description") }}
- </div>
- </div>
-
- <div class="grid gap-4 md:grid-cols-2">
- <RouterLink :to="{ name: 'ping' }" class="tool-card glass-card">
- <div class="tool-card__icon bg-blue-50 text-blue-500 dark:bg-blue-900/30 dark:text-blue-200">
- <MaterialDesignIcon icon-name="radar" class="w-6 h-6" />
+ <div class="flex-1 overflow-y-auto w-full">
+ <div class="space-y-4 p-4 md:p-6 max-w-5xl mx-auto w-full">
+ <div class="glass-card space-y-3">
+ <div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
+ {{ $t("tools.utilities") }}
</div>
- <div class="flex-1">
- <div class="tool-card__title">{{ $t("tools.ping.title") }}</div>
- <div class="tool-card__description">
- {{ $t("tools.ping.description") }}
- </div>
+ <div class="text-2xl font-semibold text-gray-900 dark:text-white">
+ {{ $t("tools.power_tools") }}
</div>
- <MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
- </RouterLink>
-
- <RouterLink :to="{ name: 'rnprobe' }" class="tool-card glass-card">
- <div
- class="tool-card__icon bg-purple-50 text-purple-500 dark:bg-purple-900/30 dark:text-purple-200"
- >
- <MaterialDesignIcon icon-name="radar" class="w-6 h-6" />
+ <div class="text-sm text-gray-600 dark:text-gray-300">
+ {{ $t("tools.diagnostics_description") }}
</div>
- <div class="flex-1">
- <div class="tool-card__title">{{ $t("tools.rnprobe.title") }}</div>
- <div class="tool-card__description">
- {{ $t("tools.rnprobe.description") }}
+ </div>
+
+ <div class="grid gap-4 md:grid-cols-2">
+ <RouterLink :to="{ name: 'ping' }" class="tool-card glass-card">
+ <div class="tool-card__icon bg-blue-50 text-blue-500 dark:bg-blue-900/30 dark:text-blue-200">
+ <MaterialDesignIcon icon-name="radar" class="w-6 h-6" />
</div>
- </div>
- <MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
- </RouterLink>
+ <div class="flex-1">
+ <div class="tool-card__title">{{ $t("tools.ping.title") }}</div>
+ <div class="tool-card__description">
+ {{ $t("tools.ping.description") }}
+ </div>
+ </div>
+ <MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
+ </RouterLink>
- <RouterLink :to="{ name: 'rncp' }" class="tool-card glass-card">
- <div class="tool-card__icon bg-green-50 text-green-500 dark:bg-green-900/30 dark:text-green-200">
- <MaterialDesignIcon icon-name="swap-horizontal" class="w-6 h-6" />
- </div>
- <div class="flex-1">
- <div class="tool-card__title">{{ $t("tools.rncp.title") }}</div>
- <div class="tool-card__description">
- {{ $t("tools.rncp.description") }}
+ <RouterLink :to="{ name: 'rnprobe' }" class="tool-card glass-card">
+ <div
+ class="tool-card__icon bg-purple-50 text-purple-500 dark:bg-purple-900/30 dark:text-purple-200"
+ >
+ <MaterialDesignIcon icon-name="radar" class="w-6 h-6" />
</div>
- </div>
- <MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
- </RouterLink>
+ <div class="flex-1">
+ <div class="tool-card__title">{{ $t("tools.rnprobe.title") }}</div>
+ <div class="tool-card__description">
+ {{ $t("tools.rnprobe.description") }}
+ </div>
+ </div>
+ <MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
+ </RouterLink>
- <RouterLink :to="{ name: 'rnstatus' }" class="tool-card glass-card">
- <div
- class="tool-card__icon bg-orange-50 text-orange-500 dark:bg-orange-900/30 dark:text-orange-200"
- >
- <MaterialDesignIcon icon-name="chart-line" class="w-6 h-6" />
- </div>
- <div class="flex-1">
- <div class="tool-card__title">{{ $t("tools.rnstatus.title") }}</div>
- <div class="tool-card__description">
- {{ $t("tools.rnstatus.description") }}
+ <RouterLink :to="{ name: 'rncp' }" class="tool-card glass-card">
+ <div
+ class="tool-card__icon bg-green-50 text-green-500 dark:bg-green-900/30 dark:text-green-200"
+ >
+ <MaterialDesignIcon icon-name="swap-horizontal" class="w-6 h-6" />
</div>
- </div>
- <MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
- </RouterLink>
+ <div class="flex-1">
+ <div class="tool-card__title">{{ $t("tools.rncp.title") }}</div>
+ <div class="tool-card__description">
+ {{ $t("tools.rncp.description") }}
+ </div>
+ </div>
+ <MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
+ </RouterLink>
- <RouterLink :to="{ name: 'rnpath' }" class="tool-card glass-card">
- <div
- class="tool-card__icon bg-indigo-50 text-indigo-500 dark:bg-indigo-900/30 dark:text-indigo-200"
- >
- <MaterialDesignIcon icon-name="route" class="w-6 h-6" />
- </div>
- <div class="flex-1">
- <div class="tool-card__title">{{ $t("tools.rnpath.title") }}</div>
- <div class="tool-card__description">
- {{ $t("tools.rnpath.description") }}
+ <RouterLink :to="{ name: 'rnstatus' }" class="tool-card glass-card">
+ <div
+ class="tool-card__icon bg-orange-50 text-orange-500 dark:bg-orange-900/30 dark:text-orange-200"
+ >
+ <MaterialDesignIcon icon-name="chart-line" class="w-6 h-6" />
</div>
- </div>
- <MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
- </RouterLink>
+ <div class="flex-1">
+ <div class="tool-card__title">{{ $t("tools.rnstatus.title") }}</div>
+ <div class="tool-card__description">
+ {{ $t("tools.rnstatus.description") }}
+ </div>
+ </div>
+ <MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
+ </RouterLink>
- <RouterLink :to="{ name: 'translator' }" class="tool-card glass-card">
- <div
- class="tool-card__icon bg-indigo-50 text-indigo-500 dark:bg-indigo-900/30 dark:text-indigo-200"
- >
- <MaterialDesignIcon icon-name="translate" class="w-6 h-6" />
- </div>
- <div class="flex-1">
- <div class="tool-card__title">{{ $t("tools.translator.title") }}</div>
- <div class="tool-card__description">
- {{ $t("tools.translator.description") }}
+ <RouterLink :to="{ name: 'rnpath' }" class="tool-card glass-card">
+ <div
+ class="tool-card__icon bg-indigo-50 text-indigo-500 dark:bg-indigo-900/30 dark:text-indigo-200"
+ >
+ <MaterialDesignIcon icon-name="route" class="w-6 h-6" />
</div>
- </div>
- <MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
- </RouterLink>
+ <div class="flex-1">
+ <div class="tool-card__title">{{ $t("tools.rnpath.title") }}</div>
+ <div class="tool-card__description">
+ {{ $t("tools.rnpath.description") }}
+ </div>
+ </div>
+ <MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
+ </RouterLink>
- <RouterLink :to="{ name: 'forwarder' }" class="tool-card glass-card">
- <div class="tool-card__icon bg-rose-50 text-rose-500 dark:bg-rose-900/30 dark:text-rose-200">
- <MaterialDesignIcon icon-name="email-send-outline" class="w-6 h-6" />
- </div>
- <div class="flex-1">
- <div class="tool-card__title">{{ $t("tools.forwarder.title") }}</div>
- <div class="tool-card__description">
- {{ $t("tools.forwarder.description") }}
+ <RouterLink :to="{ name: 'translator' }" class="tool-card glass-card">
+ <div
+ class="tool-card__icon bg-indigo-50 text-indigo-500 dark:bg-indigo-900/30 dark:text-indigo-200"
+ >
+ <MaterialDesignIcon icon-name="translate" class="w-6 h-6" />
</div>
- </div>
- <MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
- </RouterLink>
+ <div class="flex-1">
+ <div class="tool-card__title">{{ $t("tools.translator.title") }}</div>
+ <div class="tool-card__description">
+ {{ $t("tools.translator.description") }}
+ </div>
+ </div>
+ <MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
+ </RouterLink>
- <RouterLink :to="{ name: 'documentation' }" class="tool-card glass-card">
- <div class="tool-card__icon bg-cyan-50 text-cyan-500 dark:bg-cyan-900/30 dark:text-cyan-200">
- <MaterialDesignIcon icon-name="book-open-variant" class="w-6 h-6" />
- </div>
- <div class="flex-1">
- <div class="tool-card__title">{{ $t("docs.title") }}</div>
- <div class="tool-card__description">
- {{ $t("docs.subtitle") }}
+ <RouterLink :to="{ name: 'forwarder' }" class="tool-card glass-card">
+ <div class="tool-card__icon bg-rose-50 text-rose-500 dark:bg-rose-900/30 dark:text-rose-200">
+ <MaterialDesignIcon icon-name="email-send-outline" class="w-6 h-6" />
</div>
- </div>
- <MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
- </RouterLink>
+ <div class="flex-1">
+ <div class="tool-card__title">{{ $t("tools.forwarder.title") }}</div>
+ <div class="tool-card__description">
+ {{ $t("tools.forwarder.description") }}
+ </div>
+ </div>
+ <MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
+ </RouterLink>
- <RouterLink :to="{ name: 'micron-editor' }" class="tool-card glass-card">
- <div class="tool-card__icon bg-teal-50 text-teal-500 dark:bg-teal-900/30 dark:text-teal-200">
- <MaterialDesignIcon icon-name="code-tags" class="w-6 h-6" />
- </div>
- <div class="flex-1">
- <div class="tool-card__title">{{ $t("tools.micron_editor.title") }}</div>
- <div class="tool-card__description">
- {{ $t("tools.micron_editor.description") }}
+ <RouterLink :to="{ name: 'documentation' }" class="tool-card glass-card">
+ <div class="tool-card__icon bg-cyan-50 text-cyan-500 dark:bg-cyan-900/30 dark:text-cyan-200">
+ <MaterialDesignIcon icon-name="book-open-variant" class="w-6 h-6" />
</div>
- </div>
- <MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
- </RouterLink>
+ <div class="flex-1">
+ <div class="tool-card__title">{{ $t("docs.title") }}</div>
+ <div class="tool-card__description">
+ {{ $t("docs.subtitle") }}
+ </div>
+ </div>
+ <MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
+ </RouterLink>
- <RouterLink :to="{ name: 'paper-message' }" class="tool-card glass-card">
- <div class="tool-card__icon bg-blue-50 text-blue-500 dark:bg-blue-900/30 dark:text-blue-200">
- <MaterialDesignIcon icon-name="qrcode" class="w-6 h-6" />
- </div>
- <div class="flex-1">
- <div class="tool-card__title">{{ $t("tools.paper_message.title") }}</div>
- <div class="tool-card__description">
- {{ $t("tools.paper_message.description") }}
+ <RouterLink :to="{ name: 'micron-editor' }" class="tool-card glass-card">
+ <div class="tool-card__icon bg-teal-50 text-teal-500 dark:bg-teal-900/30 dark:text-teal-200">
+ <MaterialDesignIcon icon-name="code-tags" class="w-6 h-6" />
</div>
- </div>
- <MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
- </RouterLink>
+ <div class="flex-1">
+ <div class="tool-card__title">{{ $t("tools.micron_editor.title") }}</div>
+ <div class="tool-card__description">
+ {{ $t("tools.micron_editor.description") }}
+ </div>
+ </div>
+ <MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
+ </RouterLink>
- <RouterLink :to="{ name: 'rnode-flasher' }" class="tool-card glass-card">
- <div
- class="tool-card__icon bg-purple-50 text-purple-500 dark:bg-purple-900/30 dark:text-purple-200"
- >
- <img :src="rnodeLogoPath" class="w-8 h-8 rounded-full" alt="RNode" />
- </div>
- <div class="flex-1">
- <div class="tool-card__title">{{ $t("tools.rnode_flasher.title") }}</div>
- <div class="tool-card__description">
- {{ $t("tools.rnode_flasher.description") }}
+ <RouterLink :to="{ name: 'paper-message' }" class="tool-card glass-card">
+ <div class="tool-card__icon bg-blue-50 text-blue-500 dark:bg-blue-900/30 dark:text-blue-200">
+ <MaterialDesignIcon icon-name="qrcode" class="w-6 h-6" />
+ </div>
+ <div class="flex-1">
+ <div class="tool-card__title">{{ $t("tools.paper_message.title") }}</div>
+ <div class="tool-card__description">
+ {{ $t("tools.paper_message.description") }}
+ </div>
</div>
- </div>
- <div class="flex items-center gap-2">
- <a
- href="/rnode-flasher/index.html"
- target="_blank"
- class="p-2 hover:bg-gray-100 dark:hover:bg-zinc-800 rounded-lg transition-colors text-gray-400 hover:text-blue-500"
- @click.stop
- >
- <MaterialDesignIcon icon-name="open-in-new" class="size-5" />
- </a>
<MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
- </div>
- </RouterLink>
+ </RouterLink>
- <RouterLink :to="{ name: 'debug-logs' }" class="tool-card glass-card border-dashed border-2">
- <div class="tool-card__icon bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400">
- <MaterialDesignIcon icon-name="console" class="w-6 h-6" />
- </div>
- <div class="flex-1">
- <div class="tool-card__title">Debug Logs</div>
- <div class="tool-card__description">
- View and export internal system logs for troubleshooting.
+ <RouterLink :to="{ name: 'rnode-flasher' }" class="tool-card glass-card">
+ <div
+ class="tool-card__icon bg-purple-50 text-purple-500 dark:bg-purple-900/30 dark:text-purple-200"
+ >
+ <img :src="rnodeLogoPath" class="w-8 h-8 rounded-full" alt="RNode" />
</div>
- </div>
- <MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
- </RouterLink>
+ <div class="flex-1">
+ <div class="tool-card__title">{{ $t("tools.rnode_flasher.title") }}</div>
+ <div class="tool-card__description">
+ {{ $t("tools.rnode_flasher.description") }}
+ </div>
+ </div>
+ <div class="flex items-center gap-2">
+ <a
+ href="/rnode-flasher/index.html"
+ target="_blank"
+ class="p-2 hover:bg-gray-100 dark:hover:bg-zinc-800 rounded-lg transition-colors text-gray-400 hover:text-blue-500"
+ @click.stop
+ >
+ <MaterialDesignIcon icon-name="open-in-new" class="size-5" />
+ </a>
+ <MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
+ </div>
+ </RouterLink>
+
+ <RouterLink :to="{ name: 'debug-logs' }" class="tool-card glass-card border-dashed border-2">
+ <div class="tool-card__icon bg-zinc-100 text-zinc-500 dark:bg-zinc-800 dark:text-zinc-400">
+ <MaterialDesignIcon icon-name="console" class="w-6 h-6" />
+ </div>
+ <div class="flex-1">
+ <div class="tool-card__title">Debug Logs</div>
+ <div class="tool-card__description">
+ View and export internal system logs for troubleshooting.
+ </div>
+ </div>
+ <MaterialDesignIcon icon-name="chevron-right" class="tool-card__chevron" />
+ </RouterLink>
+ </div>
</div>
</div>
</div>
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────